Authentication tokens are stored in cookies after login. client/composables/useOpnApi.js automatically injects the Authorization: Bearer <token> header on every request, and the global middleware client/middleware/01.check-auth.global.js pre-loads the current user & workspaces during navigation.
If you need to generate new tokens (e.g. for external scripts) see the Authentication docs.

Why TanStack Query?

We use TanStack Query to handle server state—data that lives in the database and is accessed through our API. It gives us:

  • Automatic caching & background refetching
  • Declarative loading / error states
  • Built-in mutations with optimistic updates
  • DevTools for debugging

For purely client-side UI state (theme, modals, etc.) we still rely on Pinia – see client/stores/app.js for an example.

Reading data

<script setup>
// 1️⃣ Grab the query factory from the composable
const { list } = useWorkspaces()

// 2️⃣ Call it to register the query
const {
  data: workspaces,
  isLoading,
  isError,
  error
} = list()
</script>

<template>
  <div v-if="isLoading">Loading…</div>
  <div v-else-if="isError">{{ error.message }}</div>
  <ul v-else>
    <li v-for="ws in workspaces" :key="ws.id">{{ ws.name }}</li>
  </ul>
</template>

A query composable always returns a function (e.g. list) that you call to register the query. The options object you pass is forwarded directly to TanStack Query in case you need staleTime, enabled, etc.

Mutating data

mutateAsync returns a Promise, so we recommend chaining handlers with .then().catch() to keep control flow explicit:

<script setup>
const { create } = useWorkspaces()
const createWorkspace = create() // register the mutation

const onSubmit = (form) => {
  createWorkspace
    .mutateAsync(form)
    .then(() => useAlert().success('Workspace created!'))
    .catch((err) => useAlert().error(err.message))
}
</script>

Mutations with URL parameters

For endpoints that require IDs in the URL (like /forms/{formId}/integrations/{integrationId}), pass the required IDs when creating the mutation factory. This ensures proper cache invalidation and prevents stale closure issues:

<script setup>
const props = defineProps({
  formId: { type: Number, required: true },
  integrationId: { type: Number, required: false }
})

const { createIntegration, updateIntegration } = useFormIntegrations()

// ✅ Pass reactive IDs when creating mutations
const formId = computed(() => props.formId)
const integrationId = computed(() => props.integrationId)

const createMutation = createIntegration(formId)
const updateMutation = updateIntegration(formId, integrationId)

const save = () => {
  const mutation = props.integrationId ? updateMutation : createMutation
  
  mutation
    .mutateAsync(formData)
    .then(() => useAlert().success('Integration saved!'))
    .catch((err) => useAlert().error(err.message))
}
</script>

Why this pattern?

  • The mutation factory receives the IDs and can properly update query cache keys
  • No need to pass IDs in the mutateAsync() call
  • Reactive IDs ensure cache updates work correctly when props change
  • Cleaner separation between mutation setup and execution

Defining mutations with URL parameters in composables

In your query composable, define mutation factories that accept the required IDs as parameters:

// client/composables/query/forms/useFormIntegrations.js
import { useQueryClient, useMutation } from '@tanstack/vue-query'
import { toValue } from 'vue'
import { formsApi } from '~/api/forms'

export function useFormIntegrations() {
  const queryClient = useQueryClient()

  const createIntegration = (formId, options = {}) => {
    return useMutation({
      mutationFn: (data) => formsApi.integrations.create(toValue(formId), data),
      onSuccess: (response) => {
        const newIntegration = response.form_integration
        const currentFormId = toValue(formId)
        
        // Update integrations list cache
        queryClient.setQueryData(['forms', currentFormId, 'integrations'], (old) => {
          if (!old) return [newIntegration]
          if (!Array.isArray(old)) return old
          return [...old, newIntegration]
        })
        
        // Cache the individual integration
        queryClient.setQueryData(['forms', currentFormId, 'integrations', newIntegration.id], newIntegration)
      },
      ...options
    })
  }

  const updateIntegration = (formId, integrationId, options = {}) => {
    return useMutation({
      mutationFn: (data) => formsApi.integrations.update(toValue(formId), toValue(integrationId), data),
      onSuccess: (response) => {
        const updatedIntegration = response.form_integration
        const currentFormId = toValue(formId)
        const currentIntegrationId = toValue(integrationId)
        
        // Update individual integration cache
        queryClient.setQueryData(['forms', currentFormId, 'integrations', currentIntegrationId], updatedIntegration)
        
        // Update in integrations list
        queryClient.setQueryData(['forms', currentFormId, 'integrations'], (old) => {
          if (!Array.isArray(old)) return old
          return old.map(integration =>
            integration.id === currentIntegrationId ? { ...integration, ...updatedIntegration } : integration
          )
        })
      },
      ...options
    })
  }

  return {
    createIntegration,
    updateIntegration,
    // ... other methods
  }
}

Always use toValue() to unwrap reactive references (refs, computed) when building query keys or making API calls. This ensures the actual values are used rather than reactive objects.

Key patterns:

  • Factory functions: Return useMutation() instances rather than calling them directly
  • Reactive ID handling: Use toValue() to extract values from refs/computed properties
  • Consistent cache keys: Match the hierarchical structure used in queries
  • Optimistic updates: Update cache immediately in onSuccess to avoid refetching

Optimistic updates & cache layer

All mutations live in their composable (client/composables/query/*). After a successful API call they update the cache manually using queryClient.setQueryData so the UI reflects changes instantly without needing an extra network round-trip.

If you need to invalidate and refetch, every composable exposes invalidate() which calls queryClient.invalidateQueries for its namespace:

import { useWorkspaces } from '@/composables/query'

useWorkspaces().invalidate() // refetch every workspace query

Anatomy of a composable (quick tour)

Take useWorkspaces.js as an example:

  1. Querieslist(), detail(id)
  2. Mutationscreate(), update(), remove()
  3. Utilitiesinvalidate()

Each query/mutation is a thin wrapper around workspaceApi.*, keeps its own queryKey, and touches only the part of the cache it owns. This isolation makes cache behaviour predictable and prevents accidental over-fetching.

Waiting for completion with suspense()

Every query instance exposes a suspense() method that returns a Promise resolving once the data is loaded. Our auth middleware uses it to block navigation until the user and workspace lists are ready:

// client/middleware/01.check-auth.global.js (excerpt)
const userQuery = useAuth().user()
const workspacesQuery = useWorkspaces().list()

await Promise.all([
  userQuery.suspense(),
  workspacesQuery.suspense(),
])

Loading & error states

Every query exposes booleans such as isLoading, isFetching, isError. Mutations expose isPending. Use them to drive spinners, disabled states, or retry logic.

isFetching && data is perfect for subtle “background update” indicators.

TL;DR

  • Use TanStack Query composables for anything that comes from the API.
  • Use Pinia only for UI or session state.
  • Mutate with mutateAsync().then().catch().
  • Call invalidate() when you really need a refetch—otherwise rely on optimistic updates.