TanStack Query Integration
Load, submit, and cache form data with React Query
Overview
Combine @letar/forms with TanStack Query for a complete data flow: fetch data → populate form → submit → invalidate cache.
Edit Form Pattern
Load existing data with useQuery, populate the form, submit with useMutation:
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Form } from '@letar/forms'
import { ProductCreateFormSchema } from '@/generated/form-schemas'
function EditProductForm({ id }: { id: string }) {
const queryClient = useQueryClient()
// Load product data
const { data: product, isLoading } = useQuery({
queryKey: ['product', id],
queryFn: () => fetch(`/api/products/${id}`).then((r) => r.json()),
})
// Submit mutation
const mutation = useMutation({
mutationFn: (data) =>
fetch(`/api/products/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
queryClient.invalidateQueries({ queryKey: ['product', id] })
},
})
if (isLoading) return <div>Loading...</div>
return (
<Form schema={ProductCreateFormSchema} initialValue={product} onSubmit={async (data) => mutation.mutateAsync(data)}>
<Form.AutoFields />
<Form.DebugValues />
<Form.Button.Submit disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Form.Button.Submit>
</Form>
)
}Create Form Pattern
const mutation = useMutation({
mutationFn: (data) => fetch('/api/products', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
router.push('/products')
},
})
<Form.FromSchema
schema={ProductCreateFormSchema}
initialValue={defaults}
onSubmit={async (data) => mutation.mutateAsync(data)}
submitLabel={mutation.isPending ? 'Creating...' : 'Create'}
/>Server Actions Alternative
With Next.js Server Actions, TanStack Query is optional — you can call the action directly:
import { createProduct } from '@/app/_actions/product.action'
;<Form.FromSchema
schema={ProductCreateFormSchema}
initialValue={defaults}
onSubmit={async (data) => createProduct(data)}
submitLabel="Create Product"
/>List with Query
function ProductList() {
const { data: products, isLoading } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then((r) => r.json()),
})
if (isLoading) return <Spinner />
return products.map((product) => <ProductCard key={product.id} product={product} />)
}Optimistic Updates
const mutation = useMutation({
mutationFn: updateProduct,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['products'] })
const previous = queryClient.getQueryData(['products'])
queryClient.setQueryData(['products'], (old) => old.map((p) => (p.id === newData.id ? { ...p, ...newData } : p)))
return { previous }
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['products'], context.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})Option sources: @letar/forms-query
A separate package with TanStack Query adapters for Select and Combobox — both skins. @letar/forms does not
depend on it.
bun add @letar/forms-queryimport {
fromSearchQuery,
fromSelectedQuery,
type SearchQueryOptions,
type SelectedQueryOptions,
useQueryOptions,
} from '@letar/forms-query'
// A whole dictionary — Select
const regions = useQueryOptions(useFindManyRegion(), toOption)
<Form.Field.Select name="regionId" {...regions.fieldProps} />
// A growing catalog — Combobox: `enabled` by `minChars` and `keepPreviousData` are added for you.
// The hooks live at module level: the rules of hooks do not allow calling them inside an arrow in JSX
const useCategorySearch = (search: string, options: SearchQueryOptions) =>
useFindManyCategory({ where: { name: { contains: search } }, take: 20 }, options)
const useCategoryById = (id: string, options: SelectedQueryOptions) =>
useFindUniqueCategory({ where: { id } }, options)
<Form.Field.Combobox
name="categoryId"
useQuery={fromSearchQuery(useCategorySearch)}
useSelected={fromSelectedQuery(useCategoryById)}
getLabel={(c) => c.name}
getValue={(c) => c.id}
/>useLoaderQuery(key, loadOptions)turns a promise loader (server action,fetch) into a query with a cache and a key.useInvalidateAfter(keys)wrapsonCreate/onUpdate: the list is refetched before the new option reaches the field. ZenStack hooks (useCreateX/useUpdateX) invalidate themselves — no wrapper needed; for mutations outside them useuseInvalidateModelsfrom@letar/forms-query/zenstack.useZenStackOptions(result, map)(@letar/forms-query/zenstack) isuseQueryOptionsfor ZenStack reads: rows with$optimistic(temporary records ofoptimisticUpdate: true) becomepending— visible, not selectable. Pair it withctx.optimistic(...)inonCreatefor an instant create.