When to Use Global State vs Query Cache

Misplacing server-derived data into a global store — or pushing ephemeral UI toggles into the query cache — is one of the most common sources of stale reads, phantom re-renders, and failed rollbacks in React applications. This page gives you a decision framework, implementation steps, and production-safe patterns for enforcing the boundary described in Client vs Server State Boundaries. If you are simultaneously evaluating which library to reach for, React Query vs Redux for Server State covers the trade-offs at the tool level.


Global Store vs Query Cache decision flow A flowchart showing that UI-only data (modal state, form drafts, navigation flags) routes to the global store, while server-derived data (user records, paginated lists, config flags) routes to the query cache with staleTime and gcTime controls. Does this data come from the server? No Global Store modal, form draft, nav flags, UI theme Yes Query Cache users, lists, config, pagination, entities Shared across multiple routes? Yes Normalize ID-keyed entity map No Raw cache entry staleTime + gcTime

Diagnostic Checklist

Before choosing a home for a piece of state, verify the following:

  • Re-renders spike without prop changes. Volatile data mixed into a global store propagates selector updates to unrelated components.
  • UI shows stale data after a background refetch. The query cache updated, but a copied value in the global store was not refreshed.
  • Optimistic updates survive server errors. Rollback logic is missing or tied to synchronous store state rather than the cache snapshot.
  • The same entity appears under two or more query keys. Normalization is absent; updates to one key do not propagate to the other.
  • Loading spinners persist after a mutation resolves. isPending is derived from a global boolean flag rather than mutation.status.

Step 1 — Classify data by origin and lifecycle

Goal: assign each piece of state to exactly one home before writing code.

Apply this rule: if the data originates on a server, changes via a network request, or must stay consistent across browser tabs, it belongs in the query cache. Everything else — modal visibility, active tab index, form draft values, client-side filter toggles, animation flags — belongs in a global store (Zustand, Jotai, Redux, or React context, depending on scope).

A quick classification table:

Data Origin Home
Authenticated user profile API (GET /me) Query cache
Modal open/closed User interaction Global store
Paginated project list API (GET /projects) Query cache
Active sidebar tab User interaction Global store
Feature flags API (GET /flags) Query cache, staleTime: Infinity
Form draft (unsaved) User input Global store or local state

Cache Behavior Analysis. React Query v5 tracks each query by key and assigns it a lifecycle: fresh, stale, or inactive. When staleTime expires the entry becomes stale but is not removed until gcTime (default 5 minutes) passes with no active subscriber. Placing UI-only booleans in the cache bypasses this lifecycle machinery and can produce spurious isFetching states on mount.


Step 2 — Route UI state to the global store, server state to the query cache

Goal: implement isolated stores for each concern so neither can contaminate the other.

import { create } from 'zustand';
import { useQuery, useQueryClient } from '@tanstack/react-query';

// --- UI state (Zustand) ---
// Owns only synchronous, ephemeral interaction data.
interface UIState {
  modalOpen: boolean;
  selectedUserId: string | null;
  openModal: (userId: string) => void;
  closeModal: () => void;
}

const useUIStore = create<UIState>((set) => ({
  modalOpen: false,
  selectedUserId: null,
  openModal: (userId) => set({ modalOpen: true, selectedUserId: userId }),
  closeModal: () => set({ modalOpen: false, selectedUserId: null }),
}));

// --- Server state (TanStack Query v5) ---
// Fetches, caches, and background-refreshes user data independently.
interface User {
  id: string;
  name: string;
  role: string;
}

function useUser(userId: string | null) {
  return useQuery<User>({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('Failed to fetch user');
      return res.json() as Promise<User>;
    },
    enabled: userId !== null,
    staleTime: 1000 * 60 * 5,   // 5 min — avoids refetch on every re-open
    gcTime: 1000 * 60 * 15,     // 15 min — keeps entry alive between modal opens
  });
}

// --- Integration component ---
// UI store drives WHICH user to fetch; query cache drives the data.
export function UserModal() {
  const { modalOpen, selectedUserId, closeModal } = useUIStore();
  const { data: user, isPending, isError } = useUser(selectedUserId);

  if (!modalOpen) return null;

  return (
    <dialog open aria-modal aria-label="User details">
      {isPending && <p>Loading…</p>}
      {isError && <p>Could not load user.</p>}
      {user && (
        <>
          <h2>{user.name}</h2>
          <p>Role: {user.role}</p>
        </>
      )}
      <button onClick={closeModal}>Close</button>
    </dialog>
  );
}

Cache Behavior Analysis. Setting enabled: userId !== null tells React Query to skip the fetch entirely when no user is selected — the query stays in idle status and no entry is written to the cache. When selectedUserId changes to a real ID, the query transitions to loading, fires the fetch, and writes the result under ['user', userId]. Subsequent modal opens for the same user hit the staleTime window and render immediately from cache, with a background refetch only after 5 minutes have elapsed.

Trade-offs:

  • staleTime: 0 (default) causes a background refetch every time the modal reopens. Raise it proportionally to how quickly user data changes on your platform.
  • gcTime controls how long an unused entry lingers. Shrink it on memory-constrained devices; expand it on dashboards where re-navigation is frequent.
  • structuralSharing (enabled by default in v5) means React Query deep-compares the new response to the cached one and returns the same object reference when nothing changed, preventing downstream re-renders.

Step 3 — Implement transactional rollback for optimistic mutations

Goal: ensure that server errors always revert the UI to the last confirmed server state, regardless of what the global store holds.

import { useMutation, useQueryClient } from '@tanstack/react-query';

interface UpdateUserPayload {
  userId: string;
  name: string;
}

export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation<User, Error, UpdateUserPayload, { previousUser: User | undefined }>({
    mutationFn: async ({ userId, name }) => {
      const res = await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      });
      if (!res.ok) throw new Error('Update failed');
      return res.json() as Promise<User>;
    },

    // 1. Snapshot the current cache entry before the request fires.
    onMutate: async ({ userId, name }) => {
      // Cancel any in-flight refetch so it does not overwrite our optimistic value.
      await queryClient.cancelQueries({ queryKey: ['user', userId] });

      const previousUser = queryClient.getQueryData<User>(['user', userId]);

      // Apply the optimistic update directly to the cache.
      queryClient.setQueryData<User>(['user', userId], (old) =>
        old ? { ...old, name } : old
      );

      return { previousUser };
    },

    // 2. On failure, roll back to the snapshot captured in onMutate.
    onError: (_err, { userId }, context) => {
      if (context?.previousUser) {
        queryClient.setQueryData<User>(['user', userId], context.previousUser);
      }
    },

    // 3. On success or failure, sync with the server's authoritative state.
    onSettled: (_data, _err, { userId }) => {
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });
}

Cache Behavior Analysis. cancelQueries issues an AbortSignal to any pending fetch for this key, preventing a race where the in-flight response lands after your optimistic write and reverts it. setQueryData writes directly to the cache synchronously — components subscribed to ['user', userId] re-render immediately with the optimistic value. If onError fires, setQueryData runs again with the snapshot, and onSettled triggers a fresh network fetch to reconcile with the actual server record.

Trade-offs:

  • cancelQueries adds ~1 ms of overhead per mutation but eliminates the most common class of race conditions in concurrent UIs.
  • Storing rollback context in the mutation’s context type parameter (the fourth generic) keeps snapshot logic co-located with the mutation and avoids polluting the global store with temporary pre-mutation copies.
  • onSettled always invalidates, even on success. If your mutation response already contains the updated record, use setQueryData in onSuccess instead of invalidateQueries to avoid the extra network round-trip.

Edge Cases & Gotchas

Global store overwriting fresh cache data on mount

If a component copies query cache data into Zustand during initialization (e.g., inside a useEffect that runs once), and then a background refetch updates the cache, the Zustand slice is now stale. The next render reads from the old copy.

Resolution: derive display values directly from the query result rather than copying them into a store:

// Fragile — copies once, then diverges
const [localName, setLocalName] = useState('');
useEffect(() => { if (data) setLocalName(data.name); }, []); // bug: empty dep array

// Safe — always reads from the canonical cache entry
const { data } = useUser(userId);
const displayName = data?.name ?? '';

Volatile query keys triggering unnecessary refetches

Including timestamps, random IDs, or un-debounced search strings in a query key breaks cache identity — React Query treats each variation as a distinct resource and fires a new fetch, even when the data has not changed.

Resolution: stabilize keys to deterministic values and debounce user inputs before binding them to query parameters:

import { useDeferredValue } from 'react';

function useSearchResults(rawQuery: string) {
  const query = useDeferredValue(rawQuery); // stabilizes across rapid keystrokes
  return useQuery({
    queryKey: ['search', query],
    queryFn: () => fetchSearch(query),
    enabled: query.length > 2,
    staleTime: 1000 * 30,
  });
}

Duplicate entity references under multiple query keys

When a /projects list response embeds full user objects and a /users/:id endpoint returns the same user, the cache holds two independent copies. An update through either key does not propagate to the other.

Resolution: strip relational data from list responses at the API layer if possible, or implement a thin normalization transform that writes entity updates to a canonical key and stores only IDs in list queries. See Data Normalization & Query Key Design for normalization patterns.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Component re-renders after every route change despite identical query data staleTime: 0 (default) causes a background refetch on every mount; isFetching: true triggers a re-render even when the data object is structurally identical Set staleTime to match the actual freshness requirement of the data; enable structuralSharing (on by default) so unchanged responses return the same reference
Optimistic update survives a 500 response and UI stays in a “success” state onError rollback is missing or rolls back to a Zustand slice that had already been separately mutated Snapshot the cache in onMutate via getQueryData, restore it in onError via setQueryData, and derive UI success state from mutation.isSuccess rather than a global boolean
Memory footprint grows unboundedly during a long session with many route visits gcTime is set too high (or Infinity) on queries that return large payloads, and the cache retains entries long after components unmount Tune gcTime per query based on payload size and revisit frequency; use queryClient.removeQueries in route teardown for large one-off payloads

Frequently Asked Questions

Can I store modal visibility or form drafts in the query cache?

No. The query cache is optimized for server-derived, potentially shared, and background-refreshable data. Storing ephemeral UI state there pollutes the cache namespace, triggers unnecessary re-renders when the cache is invalidated, and forces serialization overhead on data that will never be fetched from a server. Keep modal flags and draft values in Zustand, Jotai, or local component state.

How do I prevent a global store from overwriting fresh cache data on mount?

Defer global state initialization until the query’s isSuccess flag is true, or derive store slices from cache data using selectors rather than copying values at mount time. Direct copies create a second source of truth that diverges the moment the cache receives a background refetch — the stale copy in Zustand outlasts the fresh copy in the cache and wins the next render.

When should I normalize cache data instead of storing raw API responses?

Normalize when the same entity appears under multiple query keys or multiple routes must reflect the same update simultaneously. For isolated, single-use views the normalization overhead rarely pays off. Benchmark heap allocation before committing to a normalization layer in high-throughput SaaS applications — the nested data flattening techniques guide covers when the trade-off becomes worthwhile.