From coding-agent
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 claudepluginhub devjarus/coding-agentThis skill uses the workspace's default tool permissions.
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.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
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.