Client vs Server State Boundaries
Mixing UI-driven and API-driven state in the same store is one of the most reliable ways to introduce cache coherency failures, memory bloat, and hydration race conditions in production React applications. This page works through the architectural decisions, implementation patterns, and rollback mechanics needed to draw — and enforce — a hard boundary between the two. It sits within State Architecture & Cache Fundamentals alongside Cache Layer Architecture, which covers where each cache tier lives in the stack, and Normalization Principles for UI, which governs how server payloads are shaped before they enter the cache.
Diagnostic Checklist
You need this page if your application exhibits any of the following:
- A mutation succeeds on the server but the UI continues displaying the old value seconds later.
useQuerydata and a Redux/Zustand slice contain the same entity, and they diverge after an update.- Hydration errors (
Text content does not match server-rendered HTML) appear only under concurrent load. - Memory profiler shows query cache growing unboundedly across navigation events.
- Optimistic updates flash the new value, then snap back to stale data after the network response arrives.
- A
refetchOnWindowFocusburst fires on every tab switch, hammering a downstream service that is already struggling.
Prerequisites
Before implementing the patterns below, ensure you understand:
- Reference vs Value Storage Models — the choice between storing entity copies versus ID references determines whether a cache update propagates to one subscriber or all of them simultaneously.
- Cache Layer Architecture — knowing which tier (browser memory, CDN, HTTP cache) owns a given resource prevents conflicting invalidation signals.
- Stale-While-Revalidate implementation — the SWR pattern is the default revalidation strategy in TanStack Query; misunderstanding it leads to over-fetching or serving perpetually stale data.
- TanStack Query v5 basics:
useQuery,useMutation,useQueryClient, and theQueryClientProvidersetup. - The distinction between
staleTime(when background refetch triggers) andgcTime(when an inactive entry is removed from memory).
Implementation 1: Establishing Ownership with Explicit Query Keys
The first task is to assign every piece of state a definitive owner. This is not a philosophical exercise — it directly controls which invalidation path the runtime takes.
Steps:
- Audit every
useState, Zustand atom, Redux slice, anduseQuerycall in the application. Label each as client (ephemeral, UI-scoped) or server (API-backed, cross-component). - For all server-owned state, migrate the storage into a query adapter. Remove any mirror copies from local stores.
- Design structured query key arrays that encode the resource hierarchy:
['users', userId],['users', userId, 'settings']. Avoid template strings — they make prefix-matching forinvalidateQueriesunreliable. - In Next.js App Router, serialize server state from RSC into the
dehydratedStatepayload and rehydrate inside a singleHydrationBoundary. Never merge this payload into a Zustand or Redux store without schema validation.
// query-client.ts — singleton with explicit TTLs
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30 s: background refetch suppressed while fresh
gcTime: 300_000, // 5 m: inactive entries retained after unmount
retry: 2,
refetchOnWindowFocus: false, // disable for dashboards that own their refresh cycle
},
},
});
// user-queries.ts — structured key factory
export const userKeys = {
all: () => ['users'] as const,
list: (filters: Record<string, unknown>) => ['users', 'list', filters] as const,
detail: (id: string) => ['users', id] as const,
profile: (id: string) => ['users', id, 'profile'] as const,
};
// Usage: invalidate all user queries with a single prefix call
// queryClient.invalidateQueries({ queryKey: userKeys.all() });
Cache Behavior Impact: TanStack Query performs prefix matching on structured key arrays when invalidateQueries is called. Passing userKeys.all() marks every entry whose key starts with ['users'] as stale and schedules background refetches for any that have active subscribers. String-based keys break this prefix matching silently — the invalidation call succeeds but nothing re-fetches.
Configuration Trade-offs:
- Increasing
staleTimeabove 60 s reduces API call volume significantly in high-traffic UIs but risks displaying entity values that changed server-side (e.g., a balance field updated by a concurrent session). - Setting
gcTimebelowstaleTimecreates a guaranteed window where an entry is considered fresh but has already been evicted — the next subscriber triggers a full network fetch instead of returning the cached value. refetchOnWindowFocus: falseis appropriate for dashboards that manage their own polling intervals, but must be re-enabled for any query that reflects shared mutable state (e.g., document collaboration presence data).
Implementation 2: Adapter Configuration and Cache Lifecycle Alignment
Once ownership is established, the adapter’s lifecycle settings must match the backend’s cache contracts. Misalignment here is the root cause of the most common production symptom: stale data persisting minutes after a server-side change.
Steps:
- Read the
Cache-Control: max-ageors-maxageheaders from your API responses. SetstaleTimeto the same value. If the API does not emit these headers, negotiate with the backend team or default to a conservative 15–30 s. - Set
gcTimeto at least 2× the expected route-transition time. Aggressive garbage collection during rapid navigation evicts entries that are still conceptually “warm,” forcing redundant fetches on back-navigation. - For real-time data (live dashboards, notification feeds), supplement
staleTime: 0with WebSocket-driven cache patching viaqueryClient.setQueryData— do not rely solely on polling intervals. - Implement deterministic rollbacks: capture the pre-mutation snapshot in
onMutate, apply the optimistic patch, then revert inonError.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from './query-client';
interface User { id: string; name: string; email: string; }
// Read: staleTime aligned with API Cache-Control: max-age=30
export function useUser(id: string) {
return useQuery<User>({
queryKey: userKeys.detail(id),
queryFn: () => fetch(`/api/users/${id}`).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json() as Promise<User>;
}),
staleTime: 30_000,
gcTime: 300_000,
});
}
// Write: optimistic update with typed rollback context
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation<User, Error, { id: string; data: Partial<User> }, { previous: User | undefined }>({
mutationFn: ({ id, data }) =>
fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
}).then((r) => r.json() as Promise<User>),
onMutate: async ({ id, data }) => {
// Cancel any in-flight refetches that would overwrite the optimistic value
await queryClient.cancelQueries({ queryKey: userKeys.detail(id) });
// Capture snapshot for rollback
const previous = queryClient.getQueryData<User>(userKeys.detail(id));
// Apply optimistic patch
queryClient.setQueryData<User>(userKeys.detail(id), (old) =>
old ? { ...old, ...data } : old,
);
return { previous };
},
onError: (_err, { id }, context) => {
// Restore pre-mutation state synchronously
if (context?.previous !== undefined) {
queryClient.setQueryData(userKeys.detail(id), context.previous);
}
},
onSettled: (_data, _err, { id }) => {
// Always refetch after success or failure to sync with server truth
queryClient.invalidateQueries({ queryKey: userKeys.detail(id) });
},
});
}
Cache Behavior Impact: cancelQueries issues an abort signal to any in-flight fetch for the matching key before onMutate applies the optimistic patch. Without this call, a slow in-flight response arriving after the optimistic update will overwrite the patch with the pre-mutation server value — producing the “snap-back” flicker described in the diagnostic checklist. The onSettled invalidation runs regardless of mutation outcome, ensuring the cache converges on server truth even if the rollback path was taken.
Configuration Trade-offs:
- Omitting
cancelQueriesinonMutateis safe only ifstaleTimeis long enough that no background refetch is active at mutation time — a fragile assumption in production. onSettledinvalidation triggers a background refetch that may be redundant if the mutation response already returns the updated entity. Pass the response toqueryClient.setQueryDatainstead of invalidating to avoid the extra round-trip; but only do this if the API contract guarantees the response is always the authoritative final state.- Setting
retry: 0on mutations prevents the adapter from silently retrying failed writes, which would double-apply side effects on non-idempotent endpoints.
Implementation 3: Normalization and RTK Query Tag Synchronization
For applications with complex relational data — orders containing line items, threads containing messages — flat normalization with tag-based invalidation prevents the partial-write scenarios that cause orphaned cache references. Understanding designing stable query keys for React Query first will make the RTK Query tag system easier to reason about.
Steps:
- Define
tagTypesat the API slice level. Every resource type that can be independently updated needs its own tag. - Attach
providesTagsto read endpoints with both the entity type and its ID. This letsinvalidatesTagstarget a single record without invalidating the entire collection. - Implement
onQueryStartedfor mutations where immediate UI feedback is required. Always callpatchResult.undo()inside the error handler. - For deeply nested GraphQL responses, flatten at the network boundary using a normalizer before the result reaches
providesTags. See flattening deeply nested GraphQL responses for the full transformation pattern.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
interface User { id: string; name: string; role: string; }
interface UserUpdate { id: string; data: Partial<User>; }
export const userApi = createApi({
reducerPath: 'userApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: (builder) => ({
getUser: builder.query<User, string>({
query: (id) => `/users/${id}`,
// Tags bind this cache entry to the User invalidation graph
providesTags: (_result, _error, id) => [{ type: 'User', id }],
}),
listUsers: builder.query<User[], void>({
query: () => '/users',
// 'LIST' sentinel invalidates the collection when any member changes
providesTags: (result) => [
...(result ?? []).map(({ id }) => ({ type: 'User' as const, id })),
{ type: 'User', id: 'LIST' },
],
}),
updateUser: builder.mutation<User, UserUpdate>({
query: ({ id, data }) => ({ url: `/users/${id}`, method: 'PUT', body: data }),
// Invalidates only the specific record + the collection sentinel
invalidatesTags: (_result, _error, { id }) => [
{ type: 'User', id },
{ type: 'User', id: 'LIST' },
],
onQueryStarted: async ({ id, data }, { dispatch, queryFulfilled }) => {
// Optimistic patch: applied immediately, reverted on error
const patch = dispatch(
userApi.util.updateQueryData('getUser', id, (draft) => {
Object.assign(draft, data);
}),
);
try {
await queryFulfilled;
} catch {
patch.undo(); // Reverts the Immer draft to its pre-patch state
}
},
}),
}),
});
export const { useGetUserQuery, useListUsersQuery, useUpdateUserMutation } = userApi;
Cache Behavior Impact: RTK Query’s tag system forms a bipartite graph between cache entries (tagged via providesTags) and mutation invalidation signals (sent via invalidatesTags). When updateUser settles, RTK Query walks the graph, marks every entry providing a matching tag as invalidated, and triggers background refetches only for entries that currently have active subscribers. Entries with no active subscribers are marked stale but not refetched until the next subscriber mounts — preventing unnecessary network requests for off-screen data.
Configuration Trade-offs:
- The
'LIST'sentinel pattern is idiomatic but coarse: any single-record update invalidates the entire list query. For paginated lists with thousands of entries, prefer manual cache updates viautil.updateQueryDataon the list endpoint to avoid a full re-fetch of the visible page. onQueryStartedruns synchronously before the mutation fetch resolves. This is the correct place for optimistic updates. Attempting optimistic updates inonSuccessis too late — the server response has already arrived, making the update non-optimistic.- Splitting a large domain into multiple
createApislices requires coordinating invalidation across slice boundaries, which RTK Query does not support natively. UsequeryClient-level invalidation via the sharedinvalidationSubscriptionsmechanism or consolidate into a single API slice with logical sub-domains.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Stale entity visible after confirmed mutation | invalidateQueries key does not match the active query key (array shape mismatch or extra filter parameters in the active key) |
Run queryClient.getQueryCache().getAll().map(q => q.queryKey) in the browser console to inspect live keys; compare against the key passed to invalidateQueries |
| Memory grows continuously across SPA navigation | gcTime set to Infinity or entries never losing all subscribers due to a leaked observer (component unmounting without cleanup) |
Set an explicit gcTime (default 5 min). Use React DevTools to identify components that remain mounted after route change. |
| Optimistic update snaps back to old value 200–500 ms after interaction | In-flight background refetch was not cancelled before onMutate applied the patch; slow response overwrites the optimistic value |
Add await queryClient.cancelQueries({ queryKey }) as the first line of onMutate |
HydrationBoundary throws dehydratedState mismatch error |
Server-side query ran with different default options than the client (e.g., different staleTime) causing structural sharing to produce mismatched hashes |
Ensure the QueryClient used in RSC and the one passed to HydrationBoundary are constructed with identical defaultOptions |
Frequently Asked Questions
How do I decide if state belongs to the client or server cache?
If the data persists across sessions, originates from an API, or must be visible in more than one component without prop-drilling, it is server state and belongs in the query cache. If it is ephemeral (dropdown open, form draft, animation step), strictly component-scoped, and has no network origin, it belongs in useState, useReducer, or a UI-only store like Zustand. When in doubt, ask: “Does this state need to survive a browser refresh or appear simultaneously in two unrelated components?” If yes to either, it is server state.
What is the optimal cache lifecycle for real-time SaaS dashboards?
Use staleTime: 0 paired with explicit polling intervals (refetchInterval) rather than refetchOnWindowFocus. For sub-second freshness, inject WebSocket messages directly into the cache via queryClient.setQueryData and disable polling entirely — polling and WebSocket push together cause double-renders and out-of-order updates. Set gcTime to at least the average tab session length (typically 15–30 min for dashboards) to prevent eviction of data the user will scroll back to.
Can I mix TanStack Query server state with Zustand client state?
Yes, and the combination works well when the contract is explicit: Zustand holds only UI state (selected row ID, active modal, filter panel open state) and never mirrors data from the query cache. Components read entity data exclusively via useQuery; they pass selected IDs from Zustand as query key parameters. The moment you copy a query result into a Zustand slice to “cache it locally,” you have two authoritative sources and invalidation logic becomes impossible to maintain.
Why does invalidateQueries not re-render my component after a mutation?
Two common causes: (1) the query key array passed to invalidateQueries does not match the active query’s key — arrays are compared element-by-element, so ['users', '123'] and ['users', 123] are different keys (string vs number). (2) The component’s useQuery call uses enabled: false, which prevents re-fetches triggered by invalidation. Inspect the live cache with queryClient.getQueryCache().getAll() and confirm the key types match exactly.
Related
- State Architecture & Cache Fundamentals — the parent section covering the full spectrum of cache design decisions for frontend applications.
- React Query vs Redux for Server State — a side-by-side comparison of adapter contracts, normalization approaches, and migration paths when your team is evaluating tools.
- When to Use Global State vs Query Cache — decision framework for the grey-area cases where state genuinely straddles both categories.
- Cache Layer Architecture — where browser memory cache, HTTP cache, and CDN edge cache sit relative to each other and how they interact with query adapter TTLs.
- Mutation Sync & Rollback — patterns for handling failed mutations, including partial writes across relational entities and coordinated rollback across multiple query keys.