.agents/skills/frontend-query-mutation/references/runtime-rules.md
mutate vs mutateAsyncPrefer contract-shaped queryOptions(...).
When required input is missing, prefer input: skipToken instead of placeholder params or non-null assertions.
Use enabled only for extra business gating after the input itself is already valid.
import { skipToken, useQuery } from '@tanstack/react-query'
// Disable the query by skipping input construction.
function useAccessMode(appId: string | undefined) {
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
input: appId
? { params: { appId } }
: skipToken,
}))
}
// Avoid runtime-only guards that bypass type checking.
function useBadAccessMode(appId: string | undefined) {
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
input: { params: { appId: appId! } },
enabled: !!appId,
}))
}
Bind invalidation in the service-layer mutation definition. Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate.
Use:
.key() for namespace or prefix invalidation.queryKey(...) only for exact cache reads or writes such as getQueryData and setQueryDataqueryClient.invalidateQueries(...) in mutation onSuccessDo not use deprecated useInvalid from use-base.ts.
// Service layer owns cache invalidation.
export const useUpdateAccessMode = () => {
const queryClient = useQueryClient()
return useMutation(consoleQuery.accessControl.updateAccessMode.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
})
},
}))
}
// Component only adds UI behavior.
updateAccessMode({ appId, mode }, {
onSuccess: () => toast.success('...'),
})
// Avoid putting invalidation knowledge in the component.
mutate({ appId, mode }, {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
})
},
})
.key(...)
queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() }).queryKey(...)
.mutationKey(...)
mutate vs mutateAsyncPrefer mutate by default.
Use mutateAsync only when Promise semantics are truly required, such as parallel mutations or sequential steps with result dependencies.
Rules:
mutate(...) with onSuccess or onError.await mutateAsync(...) must be wrapped in try/catch.mutateAsync when callbacks already express the flow clearly.// Default case.
mutation.mutate(data, {
onSuccess: result => router.push(result.url),
})
// Promise semantics are required.
try {
const order = await createOrder.mutateAsync(orderData)
await confirmPayment.mutateAsync({ orderId: order.id, token })
router.push(`/orders/${order.id}`)
}
catch (error) {
toast.error(error instanceof Error ? error.message : 'Unknown error')
}
When touching old code, migrate it toward these rules:
| Old pattern | New pattern |
|---|---|
useInvalid(key) in service layer | queryClient.invalidateQueries(...) inside mutation onSuccess |
| component-triggered invalidation after mutation | move invalidation into the service-layer mutation definition |
| imperative fetch plus manual invalidation | wrap it in useMutation(...mutationOptions(...)) |
await mutateAsync() without try/catch | switch to mutate(...) or add try/catch |