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.


Client vs Server State Boundary Architecture Two parallel lanes: client state flows through local component state and context into the component tree; server state flows through a query adapter (TanStack Query / RTK Query) and normalised cache store into the component tree. A hard boundary line separates the two lanes. CLIENT STATE SERVER STATE User Interaction Events modal open · form draft · filter toggle useState / useReducer / Context ephemeral · component-scoped · no TTL Network / API Responses REST · GraphQL · WebSocket payloads Query Adapter Cache staleTime · gcTime · invalidation tags Normalised Entity Store { entities, result } · ID references only Component Tree (read-only merge) hard boundary

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.
  • useQuery data 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 refetchOnWindowFocus burst 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 the QueryClientProvider setup.
  • The distinction between staleTime (when background refetch triggers) and gcTime (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:

  1. Audit every useState, Zustand atom, Redux slice, and useQuery call in the application. Label each as client (ephemeral, UI-scoped) or server (API-backed, cross-component).
  2. For all server-owned state, migrate the storage into a query adapter. Remove any mirror copies from local stores.
  3. Design structured query key arrays that encode the resource hierarchy: ['users', userId], ['users', userId, 'settings']. Avoid template strings — they make prefix-matching for invalidateQueries unreliable.
  4. In Next.js App Router, serialize server state from RSC into the dehydratedState payload and rehydrate inside a single HydrationBoundary. 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 staleTime above 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 gcTime below staleTime creates 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: false is 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:

  1. Read the Cache-Control: max-age or s-maxage headers from your API responses. Set staleTime to the same value. If the API does not emit these headers, negotiate with the backend team or default to a conservative 15–30 s.
  2. Set gcTime to 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.
  3. For real-time data (live dashboards, notification feeds), supplement staleTime: 0 with WebSocket-driven cache patching via queryClient.setQueryData — do not rely solely on polling intervals.
  4. Implement deterministic rollbacks: capture the pre-mutation snapshot in onMutate, apply the optimistic patch, then revert in onError.
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 cancelQueries in onMutate is safe only if staleTime is long enough that no background refetch is active at mutation time — a fragile assumption in production.
  • onSettled invalidation triggers a background refetch that may be redundant if the mutation response already returns the updated entity. Pass the response to queryClient.setQueryData instead 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: 0 on 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:

  1. Define tagTypes at the API slice level. Every resource type that can be independently updated needs its own tag.
  2. Attach providesTags to read endpoints with both the entity type and its ID. This lets invalidatesTags target a single record without invalidating the entire collection.
  3. Implement onQueryStarted for mutations where immediate UI feedback is required. Always call patchResult.undo() inside the error handler.
  4. 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 via util.updateQueryData on the list endpoint to avoid a full re-fetch of the visible page.
  • onQueryStarted runs synchronously before the mutation fetch resolves. This is the correct place for optimistic updates. Attempting optimistic updates in onSuccess is too late — the server response has already arrived, making the update non-optimistic.
  • Splitting a large domain into multiple createApi slices requires coordinating invalidation across slice boundaries, which RTK Query does not support natively. Use queryClient-level invalidation via the shared invalidationSubscriptions mechanism 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.