Back to System Design
System Design
hard
mid

How would you layer state management across local, URL, server, and shared client state?

Five layers, each with its own home. Local UI state → useState. Form → React Hook Form. URL state → router params (shareable, back-button-friendly). Server data → React Query/RTKQ/SWR (cache, dedup, invalidation). Shared client state → Context for rarely-changing, Zustand/Jotai for frequently-changing slices. Persistent → localStorage + small store. Keep boundaries explicit; don't put server data in Redux or theme in Zustand if not needed.

9 min read·~5 min to think through

Mature React apps have multiple kinds of state, each with its own lifecycle, scope, and right tool. Treating them uniformly creates re-render storms, duplicate sources of truth, and confused testing.

Layer 1: Local UI state — useState

jsx
const [open, setOpen] = useState(false);

For state that lives in a single component: form input, hover/focus, expand/collapse.

Rule: start here. Lift up only when proven shared.

Layer 2: Form state — React Hook Form (or similar)

jsx
const { register, handleSubmit, formState: { errors } } = useForm();

Form libraries handle: per-field state without re-rendering the whole form, validation, error state, submit lifecycle. Avoid managing every input with useState.

Layer 3: URL state — router params

Filters, search query, current page, sort order. Belongs in the URL because:

  • Sharable / bookmarkable.
  • Browser back/forward works.
  • Refresh preserves state.
  • Crawlable.
tsx
import { useSearchParams } from 'next/navigation';

const params = useSearchParams();
const query = params.get('q') ?? '';
const page = parseInt(params.get('page') ?? '1');

Layer 4: Server data — React Query / RTKQ / SWR

API responses are cached server data, not application state. Use a data-fetching library:

jsx
const { data, isLoading } = useQuery({
  queryKey: ['user', id],
  queryFn: () => api.getUser(id),
});

Get for free: cache, dedup, stale-while-revalidate, refetch on focus/reconnect, tag-based invalidation, optimistic updates, retry.

Anti-pattern: server data in Redux. You're re-implementing React Query badly.

Layer 5: Shared client state

Rarely-changing app-wide values: theme, locale, current user, feature flags.

jsx
<ThemeContext.Provider value={theme}>

</ThemeContext.Provider>

Context is fine — these change rarely, re-render cascades are infrequent.

Frequently-changing app-wide state: cross-component UI (sidebar collapsed, modal stack, command-palette open), client-side filters that don't belong in URL.

jsx
const useStore = create((set) => ({
  sidebarOpen: false,
  toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
}));

function Sidebar() {
  const open = useStore(s => s.sidebarOpen);   // subscribes only to this slice
  return open ? <Aside /> : null;
}

Zustand / Jotai give fine-grained subscription — only components reading the changed slice re-render. Context would re-render every consumer on every change.

Layer 6: Persistent client state

User preferences, drafts, recently-viewed:

js
// On change
localStorage.setItem('prefs', JSON.stringify(prefs));
// On mount
const saved = JSON.parse(localStorage.getItem('prefs') ?? '{}');

Wrap with a small store (Zustand has a persist middleware) so the state hydrates on load and syncs across tabs.

For larger data (offline mode), use IndexedDB.

Layer 7: Real-time / collab

For multi-user collab: CRDT (Yjs, Automerge) or OT-based libraries. The state model is fundamentally different (operations, conflict resolution).

Decision flow

ts
Is it server data? → React Query
Is it form state? → React Hook Form
Should it be in the URL? → Router params
Is it local to one component? → useState
Is it shared across siblings? → Lift, or Context
Is it app-wide and changes often? → Zustand / Jotai
Is it app-wide and rarely changes? → Context
Is it persistent? → localStorage + small store

Patterns that scale

  • Compose with children prop to avoid re-render cascades through Providers.
  • Split contexts by update frequency (one for value, one for setter).
  • Lift state down, not up: keep blast radius small.
  • Selectors on global stores to subscribe to slices.
  • Use useSyncExternalStore for tearing-free concurrent rendering of external stores.

Anti-patterns

  • Server data in Redux / Zustand / Context.
  • One mega global store with everything.
  • Inline object literals as Context value (every consumer re-renders).
  • Filters in client state instead of URL.
  • Reinventing React Query with hand-rolled fetch + cache.
  • Lifting local state global "just in case."

Examples

Auth: server data (current user) via React Query + Context for distribution + cookie for token + localStorage for "remember device."

Theme: Context (rarely changes) + localStorage for persistence + SSR cookie for no-flash.

Cart: server-synced via React Query + optimistic local updates in Zustand + URL for "step" on multi-step checkout.

Search: query in URL + cached results in React Query + recent searches in localStorage.

Mental model

State has lifecycles — pick the layer that matches. Server data is owned by the server; the client caches it. URL is state visible and bookmarkable. Form state is its own concern. Global client state should be a small minority of the total. Premature global state is the #1 source of React perf pain.

Follow-up questions

  • Why prefer React Query over Redux for server data?
  • When does Zustand beat Context?
  • What goes in URL state vs client state?
  • How does useSyncExternalStore help concurrent rendering?

Common mistakes

  • Server data in Redux — reinvents React Query.
  • Inline object Context value — re-render storm.
  • Filters in client state instead of URL — broken back/sharing.
  • Lifting local state up unnecessarily.
  • One global store for everything.
  • Form state in useState instead of a form lib.

Performance considerations

  • Right state placement is the #1 React perf lever after rendering strategy. Wrong placement = re-render storms. Granular subscription (Zustand) eliminates Context cascade. Server-state caching (React Query) eliminates redundant fetches.

Edge cases

  • Cross-tab sync via storage event or BroadcastChannel.
  • Persistent state needs careful hydration (SSR mismatch trap).
  • Server Components don't have client state — state lives only in client components.
  • Real-time updates from WebSocket need to integrate with the cache layer.
  • Optimistic updates require rollback on server reject.

Real-world examples

  • Linear: Zustand + IndexedDB sync + React Query.
  • Notion: complex layered state with optimistic updates.
  • Redux Toolkit + RTKQ for large React+Redux codebases.
  • Most new React apps use React Query + Zustand or Context.

Senior engineer discussion

Seniors categorize state by lifecycle and pick tools per layer. They use React Query by default for server state, Context sparingly for rarely-changing config, Zustand/Jotai for fine-grained subscription, and URL state for anything shareable. They limit global state to a small minority of the codebase.

Related questions