skills/frontend/tanstack/SKILL.md
TanStack ecosystem — Query v5 for server state, Router v1 for type-safe routing, Table v8 for headless data grids, Form v1 for form state. Covers data fetching, caching, mutations, optimistic updates, URL state, and state management decisions.
npx skillsauth add devjarus/coding-agent tanstackInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The modern React data layer. Query handles server state (~80% of app data), Router handles navigation + URL state, Table handles data grids, Form handles form state.
Pick the right tool for the right state:
Server data (API responses) → TanStack Query
URL state (search, filters) → TanStack Router validateSearch / nuqs
Shared UI state (sidebar, theme) → Zustand (or Context if simple)
Local component state → useState / useReducer
Form state → TanStack Form (or just useState for simple forms)
The rule: If data comes from a server, use Query. If it's in the URL, use Router. If it's local UI, use React state. Only add Zustand when Context causes re-render problems.
Server state: fetching, caching, background refetching, mutations.
// Define query options (reusable across components)
const postsQueryOptions = queryOptions({
queryKey: ['posts', { status }],
queryFn: () => fetchPosts(status),
staleTime: 5 * 60 * 1000, // 5 min
})
// In component
const { data, isPending, error } = useQuery(postsQueryOptions)
// Or with Suspense
const { data } = useSuspenseQuery(postsQueryOptions)
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
// Optimistic update
const mutation = useMutation({
mutationFn: updatePost,
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ['posts'] })
const previous = queryClient.getQueryData(['posts'])
queryClient.setQueryData(['posts'], (old) => [...old, newPost])
return { previous }
},
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts'], context.previous) // rollback
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
onSuccess/onError callbacks on useQuery — removed in v5. Use useEffect or handle in the component.gcTime not cacheTime — renamed in v5.isPending not isLoading — renamed in v5. isLoading = isPending && isFetching (first load only).placeholderData not keepPreviousData — renamed. Use placeholderData: keepPreviousData (import the function).useSuspenseQuery with enabled — not supported. Use useQuery with enabled or useSuspenseQuery without.fetch doesn't throw on 4xx/5xx. Check response.ok.['posts'] not 'posts'. Include dependencies: ['posts', { status, page }].data into useState.Type-safe routing with data loading and URL state management.
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
validateSearch: z.object({
page: z.number().default(1),
search: z.string().optional(),
}),
loaderDeps: ({ search }) => ({ page: search.page }),
loader: ({ context, deps }) =>
context.queryClient.ensureQueryData(postsQueryOptions(deps.page)),
component: PostsPage,
})
function PostsPage() {
const { page, search } = Route.useSearch()
const { data } = useSuspenseQuery(postsQueryOptions(page))
const navigate = Route.useNavigate()
return (
// Type-safe search param updates
<button onClick={() => navigate({ search: { page: page + 1 } })}>
Next
</button>
)
}
// In loader: ensure data is cached (returns from cache if fresh)
loader: ({ context }) => context.queryClient.ensureQueryData(queryOptions),
// In component: subscribe to cache (gets updates, shows cached data instantly)
const { data } = useSuspenseQuery(queryOptions)
This eliminates loading spinners on navigation — loader prefetches, component reads cache.
loaderDeps when loader depends on search params. Without it, loader won't re-run on param changes.ensureQueryData not fetchQuery in loaders. ensureQueryData returns cached data if fresh; fetchQuery always fetches.validateSearch — without it you lose type safety on URL params.Headless table logic — you provide all markup.
// IMPORTANT: memoize columns to prevent infinite re-renders
const columns = useMemo<ColumnDef<Post>[]>(() => [
{ accessorKey: 'title', header: 'Title' },
{ accessorKey: 'author', header: 'Author' },
{ accessorKey: 'createdAt', header: 'Date',
cell: ({ getValue }) => formatDate(getValue()) },
], [])
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
getSortedRowModel().<table>, <tr>, <td> yourself.Headless form state with granular reactivity.
const form = useForm({
defaultValues: { title: '', content: '' },
onSubmit: async ({ value }) => {
await createPost(value)
},
})
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
<form.Field name="title" validators={{
onChange: z.string().min(1, 'Required'),
}}>
{(field) => (
<>
<input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
{field.state.meta.errors.map(err => <span key={err}>{err}</span>)}
</>
)}
</form.Field>
</form>
)
const { data } = useQuery({
queryKey: ['posts', { page, sort, filter }],
queryFn: () => fetchPosts({ page, sort, filter }),
})
const table = useReactTable({
data: data?.rows ?? [],
pageCount: data?.pageCount ?? -1,
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
manualPagination: true,
manualSorting: true,
})
const mutation = useMutation({ mutationFn: createPost,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] })
})
const form = useForm({
onSubmit: ({ value }) => mutation.mutateAsync(value),
})
isPending not isLoading, gcTime not cacheTime.fetch resolves on 4xx — check response.ok.testing
Multi-source research method — decompose a question, fan out parallel investigators, interleaved-think each result, verify claims adversarially, synthesize a cited answer. Use for breadth-heavy research, stack comparisons, "which approach wins" questions.
testing
Decide when to use unit vs integration vs e2e tests, and when to mock vs use the real thing per dependency. Dependency injection is the enabler — without it you end up monkey-patching imports. Apply when writing tests of any kind.
development
Test-driven development process — write failing test, implement to pass, refactor. Use when implementing any feature or fixing bugs.
development
Patterns for sharing types, API contracts, and validation schemas between frontend and backend. Use when multiple domains consume the same data shapes to prevent contract drift.