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:
- Queries –
list()
, detail(id)
- Mutations –
create()
, update()
, remove()
- Utilities –
invalidate()
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.
Responses are generated using AI and may contain mistakes.