react-server-state
Skill18 starsChanged 4 months agoMITView on GitHub
Server state for React SPAs with TanStack Query v5 - the QueryClient and its defaults (staleTime, retry only for transient errors, background error toasts), one queries file per feature with a query key factory, queryOptions shared by route loaders and components, useSuspenseQuery vs useQuery, select for derived data, keeping previous data while filters change, mutation hooks, invalidation vs setQueryData, optimistic updates with rollback, infinite queries, data shared by several features, clearing the cache on logout, and test QueryClients. Use this skill whenever fetching or caching server data, writing a query or mutation, choosing a query key, invalidating or updating cached data, adding pagination or infinite scrolling, prefetching, or configuring TanStack Query. Trigger on phrases like "TanStack Query", "React Query", "useQuery", "useSuspenseQuery", "useMutation", "queryOptions", "query key", "invalidate", "invalidateQueries", "setQueryData", "optimistic update", "refetch", "staleTime", "cache", "prefetch", "ensureQueryData", "infinite scroll", "pagination", "useInfiniteQuery", "QueryClient", or "server state".
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-architectureSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-presentation-mviSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-data-layerSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-routingSkill
- react-screen-architectureSkill
- react-securitySkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-api-layerSkill
- spring-di-configurationSkill
- spring-integration-testingSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 4 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 11
Server data lives only in the Query cache: Never copy it into useState, a reducer or a context.
- Distinctive: line 13
Mutations update the cache explicitly: Every mutation states which cached data it changes, by invalidating it or by writing the new value.
- Very specific: line 110
Don't call API functions directly from components.
- Very specific: line 144
setQueryDatain addition: when the response contains the new entity. - Very specific: line 147
Use mutate, not mutateAsync, in event handlers.
- Distinctive: line 151
Only when the user needs instant feedback, like toggles, reordering or marking as done.
- Distinctive: line 175
Always cancel outgoing queries first, so a slower refetch doesn't overwrite the optimistic value.
- Distinctive: line 176
Always roll back on error and invalidate when settled.
- Very specific: line 177
When only one component shows the pending change, rendering the mutation's variables while isPending is simpler than touching the cache.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
React Server State (TanStack Query)
Principles
- Server data lives only in the Query cache. Never copy it into
useState, a reducer or a context. Derive from it during render instead (see react-screen-architecture). - One place per feature defines its queries.
api/<feature>Queries.tsholds the key factory, everyqueryOptionsand every mutation hook. Route loaders and components use the same definitions, so a key is never typed twice. - Mutations update the cache explicitly. Every mutation states which cached data it changes, by invalidating it or by writing the new value.
- No fetching in
useEffect.
QueryClient (core/api)
// src/core/api/queryClient.ts
const retryableKinds: ReadonlySet<DataErrorKind> = new Set(['network', 'timeout', 'server', 'tooManyRequests'])
export function createQueryClient() {
return new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Data is already on screen, so the page doesn't show an error. Tell the user the refresh failed.
if (query.state.data !== undefined) showBackgroundErrorToast(error)
},
}),
defaultOptions: {
queries: {
staleTime: 30_000,
retry: (failureCount, error) =>
failureCount < 2 && isDataError(error) && retryableKinds.has(error.kind),
},
mutations: {
retry: false,
},
},
})
}
export const queryClient = createQueryClient()staleTimeis the project default for how long data counts as fresh. The library default of0refetches on every mount. Override per query when data changes much more or much less often.- Retry only transient errors. Retrying a
404or403only delays the error page. - Mutations don't retry. A retried
POSTcan create something twice. refetchOnWindowFocusstays on (the default). Users switching back to the tab get fresh data.- The app creates one QueryClient. Tests create a new one per test (see Testing).
Queries File
// src/features/notes/api/notesQueries.ts
export const notesKeys = {
all: ['notes'] as const,
lists: () => [...notesKeys.all, 'list'] as const,
list: (filter: NoteFilter) => [...notesKeys.lists(), filter] as const,
details: () => [...notesKeys.all, 'detail'] as const,
detail: (noteId: string) => [...notesKeys.details(), noteId] as const,
}
export function noteListQueryOptions(filter: NoteFilter) {
return queryOptions({
queryKey: notesKeys.list(filter),
queryFn: ({ signal }) => getNotes(filter, signal),
})
}
export function noteQueryOptions(noteId: string) {
return queryOptions({
queryKey: notesKeys.detail(noteId),
queryFn: ({ signal }) => getNote(noteId, signal),
})
}- Keys are hierarchical and start with the feature name. Invalidating
notesKeys.lists()hits every list,notesKeys.allhits everything of the feature. - The key contains every variable the
queryFnuses. A filter that isn't in the key returns the cached result of another filter. - Keys contain plain values (strings, numbers, plain objects). No
Dateobjects or class instances. queryOptionsmakesqueryKey,queryFnand the data type travel together.getQueryData(noteQueryOptions(id).queryKey)is then typed.- Forward
signalto the API function, so navigating away cancels the request.
Reading Data
| Situation | Use |
| The page's main data, prefetched by the route loader | useSuspenseQuery(options), where data is always defined |
| Secondary data in one section of a page | useSuspenseQuery inside its own <Suspense>, or useQuery with inline loading and error |
| Query that depends on user input and must not suspend (search as you type, a filter that changes in place) | useQuery with placeholderData: keepPreviousData |
| Query that must wait for something | useQuery with enabled |
const { data: notes } = useSuspenseQuery(noteListQueryOptions(filter))
const { data: archivedCount } = useQuery({
...noteListQueryOptions('archived'),
select: (notes) => notes.length,
})selectderives data per component without changing the cache. The component re-renders only when the selected value changes.- Don't call API functions directly from components. They are only called from
queryFnandmutationFn. - Prefetch in the route loader with
queryClient.ensureQueryData(options)(see react-routing). For data the user will probably need next, usequeryClient.prefetchQuery(options)on hover or focus.
Mutations
Mutation hooks live in the same queries file:
export function useCreateNoteMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createNote,
onSuccess: (note) => {
queryClient.setQueryData(noteQueryOptions(note.id).queryKey, note)
return queryClient.invalidateQueries({ queryKey: notesKeys.lists() })
},
})
}
export function useDeleteNoteMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteNote,
onSuccess: (_result, noteId) => {
queryClient.removeQueries({ queryKey: notesKeys.detail(noteId) })
return queryClient.invalidateQueries({ queryKey: notesKeys.lists() })
},
})
}- Invalidate by default. It's correct even when the server changes more than the client knows (sort order, counters, timestamps).
setQueryDatain addition when the response contains the new entity. The detail page then shows it immediately, without another request.- Return the invalidation promise from
onSuccess. The mutation then stays pending until the lists have refetched, so the UI doesn't show the old list for a moment. - Cache updates go in the hook's
onSuccess, UI effects go in the call'sonSuccess.mutate(values, { onSuccess: (note) => onSaved(note.id) })(see react-presentation-mvi). The hook's callbacks run even if the component unmounted. The call's callbacks don't. - Use
mutate, notmutateAsync, in event handlers.mutateAsyncthrows on failure and needs its owntry/catch.
Optimistic updates
Only when the user needs instant feedback, like toggles, reordering or marking as done. Everything else waits for the server.
export function useArchiveNoteMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: archiveNote,
onMutate: async (noteId) => {
const { queryKey } = noteQueryOptions(noteId)
await queryClient.cancelQueries({ queryKey })
const previous = queryClient.getQueryData(queryKey)
if (previous) queryClient.setQueryData(queryKey, { ...previous, archivedAt: new Date() })
return { previous }
},
onError: (_error, noteId, context) => {
if (context?.previous) {
queryClient.setQueryData(noteQueryOptions(noteId).queryKey, context.previous)
}
},
onSettled: () => queryClient.invalidateQueries({ queryKey: notesKeys.all }),
})
}- Always cancel outgoing queries first, so a slower refetch doesn't overwrite the optimistic value.
- Always roll back on error and invalidate when settled.
- When only one component shows the pending change, rendering the mutation's
variableswhileisPendingis simpler than touching the cache.
Pagination and Infinite Lists
- Page-numbered lists: the page is part of the key and the search params. Use
placeholderData: keepPreviousDataso the old page stays visible while the next one loads. - Infinite scrolling:
infiniteQueryOptions+useInfiniteQuery(oruseSuspenseInfiniteQuery), withgetNextPageParamreading the cursor from the last page.
Data Shared by Several Features
A feature can't import another feature's keys (see react-project-structure). When two features read or change the same server data, move its API function, schemas and query definitions to core/api, and both features use them from there. Don't invalidate another feature's data with a hand-typed key like ['notes'].
Session Changes
- On logout or when the session ends, call
queryClient.clear(). The next user must not see the previous user's cached data (see react-security). - On login, nothing needs to be cleared, because the cache was cleared on logout.
Testing
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false },
},
})
}- A new QueryClient for every test. A shared client leaks cached data between tests.
retry: false, so error tests fail immediately instead of waiting through retries.- Tests go through MSW and the real API functions (see react-integration-testing).
Naming Conventions
| Thing | Convention | Example |
| Queries file | api/<feature>Queries.ts | notesQueries.ts |
| Key factory | <feature>Keys | notesKeys |
| Query options | <thing>QueryOptions | noteQueryOptions, noteListQueryOptions |
| Mutation hook | use<Verb><Thing>Mutation | useCreateNoteMutation |
Checklist: Adding a Query or Mutation
- Key from the feature's key factory, containing every variable the
queryFnuses queryOptionsin the feature's queries file,signalforwarded- Prefetched in the route loader if it's the page's main data, read with
useSuspenseQuery - Mutation hook states what it invalidates or writes, and returns the invalidation promise
- UI effects (navigation, closing a dialog) in the call's
onSuccess, cache updates in the hook's - Optimistic updates only with cancel, rollback and invalidate
- Tested with a fresh QueryClient and MSW
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

