Problem Context

State in React used to mean Redux. Then it meant Redux Toolkit. Then Context. Then Zustand. Then Jotai. Then everyone agreed "it depends" and moved on. The 2026 reality is sharper: most state belongs on the server (RSC + Server Actions), most server-cache state belongs in TanStack Query, and what's left for client-only stores is small, fast UI state โ€” open/closed, selection, draft form values.

That collapses the decision: pick a tiny client store for genuinely client-only state, fetch through React Query (or RSC) for everything else, and stop reaching for Redux on every project.

๐Ÿค” Sound familiar?
  • You wired Redux to hold the result of a single fetch() call
  • You sync the same value to Context, the URL, and localStorage with three useEffects
  • Your Context provider re-renders the entire tree every keystroke
  • You can't explain why your app needs a global store at all

This is a 2026 decision tree for client state โ€” what to use, what to skip, and the patterns that actually scale.

Concept Explanation

Categorize every piece of state by who owns it:


flowchart TD
    A["What kind of state?"] --> B{"Lives on server?"}
    B -->|Yes โ€” DB row, API result| C["TanStack Query<br/>or fetch in RSC"]
    B -->|No โ€” UI only| D{"Used by one component?"}
    D -->|Yes| E["useState / useReducer"]
    D -->|No| F{"Belongs in URL?"}
    F -->|Yes โ€” filters, tabs, page| G["useSearchParams /<br/>nuqs"]
    F -->|No| H{"Cross-cutting (theme, auth)?"}
    H -->|Yes, rarely changes| I["React Context"]
    H -->|Yes, changes often| J["Zustand / Jotai"]

    style C fill:#059669,color:#fff,stroke:#047857
    style G fill:#0891b2,color:#fff,stroke:#0e7490
    style J fill:#4f46e5,color:#fff,stroke:#4338ca

Three categories most people get wrong: (1) server cache state ends up in Redux when it should be in React Query โ€” you reinvent caching, dedup, and invalidation badly. (2) URL state ends up in a store when it should be in ?filters=... โ€” it breaks back/forward and shareable links. (3) Form stateends up globalized when it should be local โ€” a form draft is one component's problem.

Implementation

Step 1: useState / useReducer โ€” the default

// 90% of state is one component's problem
function Modal() {
  const [open, setOpen] = useState(false);
  return <button onClick={() => setOpen(true)}>Open</button>;
}

// useReducer when transitions are non-trivial
type Action = { type: 'add'; item: Item } | { type: 'remove'; id: string } | { type: 'clear' };
function cartReducer(state: Item[], action: Action): Item[] {
  switch (action.type) {
    case 'add': return [...state, action.item];
    case 'remove': return state.filter(i => i.id !== action.id);
    case 'clear': return [];
  }
}
function Cart() {
  const [items, dispatch] = useReducer(cartReducer, []);
  // ...
}

Step 2: URL as state โ€” useSearchParams + nuqs

'use client';
// nuqs gives type-safe, shallow-routed URL params
import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';

export function ProductFilters() {
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
  const [q, setQ] = useQueryState('q', parseAsString.withDefault(''));

  // URL becomes ?page=2&q=shoes โ€” back/forward, refresh, share all work
  return (
    <>
      <input value={q} onChange={e => setQ(e.target.value || null)} />
      <button onClick={() => setPage(p => p + 1)}>Next</button>
    </>
  );
}

Step 3: Context done right โ€” split value, never put state in the same provider as setters

// BAD โ€” every consumer re-renders when value OR setter identity changes
const ThemeContext = createContext<{ theme: string; setTheme: (s: string) => void }>(null!);

// GOOD โ€” split read and write contexts
const ThemeStateContext = createContext<string>('light');
const ThemeDispatchContext = createContext<(s: string) => void>(() => {});

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeDispatchContext.Provider value={setTheme}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
}

// Components that only call setTheme don't re-render when theme changes
export const useTheme = () => use(ThemeStateContext);
export const useSetTheme = () => use(ThemeDispatchContext);

Step 4: Zustand โ€” when you genuinely need a client store

'use client';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

type CartStore = {
  items: Item[];
  add: (item: Item) => void;
  remove: (id: string) => void;
  total: () => number;
};

export const useCart = create<CartStore>()(
  devtools(
    (set, get) => ({
      items: [],
      add: (item) => set(s => ({ items: [...s.items, item] })),
      remove: (id) => set(s => ({ items: s.items.filter(i => i.id !== id) })),
      total: () => get().items.reduce((s, i) => s + i.price, 0),
    }),
    { name: 'cart' },
  ),
);

// Subscribe to ONLY the slice you need โ€” selector form prevents re-renders
function CartBadge() {
  const count = useCart(s => s.items.length); // re-renders only when length changes
  return <span>{count}</span>;
}

Step 5: Jotai โ€” atomic state for fine-grained subscriptions

'use client';
import { atom, useAtom, useAtomValue } from 'jotai';

const queryAtom = atom('');
const filterAtom = atom<'all' | 'active' | 'done'>('all');
// Derived atoms recompute lazily and are memoized
const filteredCountAtom = atom((get) => {
  const q = get(queryAtom);
  const f = get(filterAtom);
  return computeCount(q, f);
});

function Counter() {
  const count = useAtomValue(filteredCountAtom); // only re-renders when count changes
  return <span>{count}</span>;
}

Pitfalls

1. Putting server data in Redux/Zustand. You end up reimplementing cache invalidation, refetching, dedup, and pagination โ€” badly. TanStack Query (or fetching in Server Components) handles all of it.

2. Context for high-frequency updates. Every consumer re-renders when the provider value changes. Mouse position in Context will re-render your whole tree on every move. Use a store with selectors.

3. Globalizing form state. Forms are component-local. Use useActionState, React Hook Form, or TanStack Form. A global "form store" is almost always the wrong abstraction.

4. Storing what should be in the URL. Filters, tabs, pagination, search query โ€” all belong in ? params so back/forward and link-sharing work. nuqs makes this trivial in App Router.

5. Premature global store.Adding Zustand on day one because "we'll need it" usually means you write Zustand selectors for things one component owns. Start with useState; promote to a store when two unrelated components need the same state.

6. Stale closures in selectors. A Zustand selector that captures a prop or other reactive value will read a stale version. Either pass the prop into the selector via the equality function, or pull the dependency out of the selector.

Practical Takeaways

  • The decision tree: server data โ†’ React Query / RSC. URL data โ†’ URL. Local UI โ†’ useState. Global UI โ†’ store.
  • Reach for useReducer when state transitions are non-trivial (4+ actions or interdependent fields).
  • Split Context into State and Dispatch when both change at different rates โ€” or use a store.
  • Default to Zustand for new client stores in 2026 โ€” small API, devtools, no provider, selector-based subscriptions.
  • Use Jotai when you have many tiny atoms that derive from each other (graph-shaped state).
  • Keep Redux Toolkit only on legacy codebases or when you genuinely need its middleware ecosystem (RTK Query, sagas).
  • Whenever you reach for a store, ask: "Could the URL hold this?" If yes, put it there.