How do you manage state? — React implementation

The honest answer to “how do you manage state in React” is “it depends on which state.” It’s a common recruiter question — deceptively simple on the surface, easy to get lost in once you start answering. The strongest signal a developer can send is to push back on the question’s shape before answering. There isn’t state — there are five different kinds of state, with different lifecycles, different ownership, and different right answers. Cram them all into one tool (Redux did this for a decade) and you pay for it forever.

This post is the long form of the answer.

Classify before you pick

The five categories that cover ~95% of what an app needs to remember:

CategoryLives forSource of truthModern React tool
Local UI stateone component’s lifetimethe componentuseState, useReducer
Shared client statemany components, one taba storeContext + reducer · Zustand · Jotai
Server stateas long as the server’s truth is currentthe serverTanStack Query, SWR, RTK Query
URL statethe lifetime of a URLthe address baruseSearchParams (React Router / Next)
Form stateopen form, with validationthe form libraryReact Hook Form, Formik

A sixth, narrower bucket — state machines (XState) — applies when the transitions themselves are the complexity. And React 19 adds first-class primitives for optimistic state and async values.

The mistake to avoid: treating server data as client state. Caching, refetching, deduplication, and stale-while-revalidate are a different problem from “the modal is open.” Pretending they’re the same is what made Redux apps painful.

Five categories of React state Local UI a toggle, a hover, a draft useState / useReducer Shared client theme, auth user, basket Context · Zustand · Jotai Server anything from an API TanStack Query / SWR URL filters, tabs, pagination useSearchParams Form fields + validation + dirty React Hook Form State machines complex transitions, modes XState · useReducer first → classify before you pick · do NOT put server data in a client store React 19 additions span several buckets useOptimistic (local + server) · useActionState (form + action) · use(promise) (Suspense-suspending async value)
Five buckets that cover almost everything. Get the classification right, the tool follows.

1. Local UI state — useState, useReducer

The default. If a piece of state belongs to one component and never needs to be read by a sibling, this is the right answer. Pushing it higher is premature.

function Disclosure({ summary, children }: { summary: string; children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(o => !o)} aria-expanded={open}>
        {summary}
      </button>
      {open && <div role="region">{children}</div>}
    </div>
  );
}

The rule that pays back: pass updater functions, not values, when the new value depends on the previous one (setOpen(o => !o) not setOpen(!open)). Closes a class of stale-closure bugs you’d otherwise debug six months later.

Reach for useReducer instead of useState when:

type State = { status: 'idle' | 'loading' | 'success' | 'error'; error?: string };
type Action =
  | { type: 'fetch_start' }
  | { type: 'fetch_success' }
  | { type: 'fetch_error'; message: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'fetch_start':   return { status: 'loading' };
    case 'fetch_success': return { status: 'success' };
    case 'fetch_error':   return { status: 'error', error: action.message };
  }
}

const [state, dispatch] = useReducer(reducer, { status: 'idle' });

The reducer is the cheap precursor to a state machine. If your reducer starts wanting to say “from loading you can only go to success or error, never back to idle” — that’s the moment to consider XState.

2. Shared client state — Context, then Zustand or Jotai

Two components on opposite ends of the tree need the same value. The naive answer is prop drilling: pass the value through every parent. For one or two levels it’s fine; past that, the trade-off depends on update rate — a value that changes once per session is cheap to drill, a value that changes every keystroke is not. Composition (children props) gets you part of the way there before reaching for Context.

The first non-naive answer is React Context:

type Theme = 'light' | 'dark';
const ThemeContext = createContext<Theme>('light');

function App() {
  const [theme, setTheme] = useState<Theme>('light');
  return (
    <ThemeContext.Provider value={theme}>
      <Page onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')} />
    </ThemeContext.Provider>
  );
}

function Page({ onToggleTheme }: { onToggleTheme: () => void }) {
  return (
    <>
      <button onClick={onToggleTheme}>toggle</button>
      <DeeplyNested />
    </>
  );
}

function DeeplyNested() {
  const theme = useContext(ThemeContext);
  return <div className={theme}>…</div>;
}

Context has a real cost most beginners miss: every consumer re-renders whenever the provider’s value changes, even if the part they read didn’t change. For a theme (changes once an hour) this is invisible. For a basket of 200 items that updates on every keystroke, it’s a disaster.

Three escape hatches, in order of effort:

  1. Split contexts. One context per concern<ThemeProvider> + <UserProvider> + <CartProvider> — so unrelated updates don’t cross-trigger.
  2. Memoise the provider value. useMemo the object so identity doesn’t churn.
  3. Switch to an external store. Zustand or Jotai. This is where most real apps end up.
// Zustand — a store outside React; subscribers re-render only when the slice they read changes.
import { create } from 'zustand';

interface CartState {
  items: CartItem[];
  add: (item: CartItem) => void;
  remove: (id: string) => void;
}

export const useCart = create<CartState>((set) => ({
  items: [],
  add:    (item) => set((s) => ({ items: [...s.items, item] })),
  remove: (id)   => set((s) => ({ items: s.items.filter(i => i.id !== id) })),
}));

// In a component — selector-based subscription.
function CartBadge() {
  const count = useCart((s) => s.items.length);   // only re-renders when length changes
  return <span>{count}</span>;
}

Zustand wins on three axes: it doesn’t need a Provider, components subscribe to selectors (not the whole store), and the API is small enough to internalise in an afternoon. Redux Toolkit + RTK Query is still a defensible default for teams that want batteries-included tooling (the time-travel devtools alone earn their keep on complex apps). Zustand and Jotai are the leaner alternatives that an increasing share of new projects pick in 2026.

Jotai is the same idea expressed atom-by-atom — each piece of state is its own atom(initial), and components subscribe via useAtom. Better when state is naturally fine-grained (a hundred independent toggles); Zustand better when there’s a coherent object you want to think about as one thing.

Under the hood, both Zustand and Redux build on useSyncExternalStore — the React 18 hook for subscribing to a non-React store correctly across concurrent renders. If you ever need to roll your own (subscribing to a Map, a WebSocket, a BroadcastChannel), reach for that primitive directly rather than useEffect + useState. It’s the supported escape hatch.

3. Server state — TanStack Query

This is where the biggest leverage is, and it’s the bucket most candidates get wrong.

Server state is fundamentally different from client state:

Hand-rolling all of this with useEffect and useState is what produces the 200-line component everyone hates. Or use a library that does it for you:

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

function CommentList({ postId }: { postId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['comments', postId],
    queryFn:  () => fetch(`/api/posts/${postId}/comments`).then(r => r.json()),
    staleTime: 30_000,
  });

  if (isLoading) return <Spinner />;
  if (error)     return <ErrorBanner err={error} />;
  return <ul>{data.map(c => <li key={c.id}>{c.body}</li>)}</ul>;
}

function CommentForm({ postId }: { postId: string }) {
  const qc = useQueryClient();
  const mutation = useMutation({
    mutationFn: (body: string) => fetch(`/api/posts/${postId}/comments`, {
      method: 'POST', body: JSON.stringify({ body }),
    }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['comments', postId] }),
  });
  // …submit handler calls mutation.mutate(body)
}

Five lines and you have: cached data, dedup across components, loading + error states, automatic refetch on tab focus / network reconnect, and a one-line invalidate that wires the mutation back to the list. Whatever else a team picks for client state, TanStack Query (or SWR, smaller and similar) is the default for server data. RTK Query is the equivalent for Redux-Toolkit shops.

Anti-pattern to flag in the interview: “we keep our API responses in Redux.” That puts server-cache concerns into a client store and you re-implement (badly) what TanStack Query gives you for free.

4. URL state — useSearchParams

The “filters and pagination get lost when the user refreshes” bug is the symptom of state that should be in the URL but isn’t.

Anything the user might:

…belongs in the URL. Tabs, filters, sort, page number, the selected item in a master/detail view. Treat the URL as your first-choice store for these.

import { useSearchParams } from 'react-router-dom'; // or 'next/navigation'

function ProductList() {
  const [params, setParams] = useSearchParams();
  const sort  = params.get('sort')  ?? 'price';
  const page  = Number(params.get('page') ?? '1');

  // Updater form preserves unrelated params (e.g. ?q=…) — the object form
  // would overwrite them, which is the most common bug with this API.
  const update = (patch: Record<string, string>) =>
    setParams((prev) => {
      const next = new URLSearchParams(prev);
      for (const [k, v] of Object.entries(patch)) next.set(k, v);
      return next;
    });

  return (
    <>
      <select value={sort} onChange={(e) => update({ sort: e.target.value, page: '1' })}>
        <option value="price">price</option>
        <option value="rating">rating</option>
      </select>

      <Pager page={page} onPage={(p) => update({ page: String(p) })} />
    </>
  );
}

The URL is also the cheapest cross-tab synchronisation: open the page in two tabs, click around in one, paste the URL into the other, you’re back where you were. No store, no coordinator, no IndexedDB.

5. Form state — React Hook Form

You can manage forms with useState per field. Past a handful of fields the boilerplate compounds — onChange handlers, validation, error display, dirty/touched flags, submit lock — and on every keystroke every other field re-renders. A form library trades a small upfront cost for all of that, and it does so by keeping field state in refs so the re-renders stay scoped.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email:    z.string().email(),
  password: z.string().min(8),
});

function LoginForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
    resolver: zodResolver(schema),
  });
  const onSubmit = handleSubmit(async (values) => {
    await fetch('/api/login', { method: 'POST', body: JSON.stringify(values) });
  });
  return (
    <form onSubmit={onSubmit}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input {...register('password')} type="password" />
      {errors.password && <span>{errors.password.message}</span>}
      <button disabled={isSubmitting}>Sign in</button>
    </form>
  );
}

React Hook Form keeps field state in refs and only re-renders the components that actually read it (the error span re-renders, the rest of the form doesn’t). Zod attaches the schema as both the validator and the inferred TypeScript type. For 80% of forms this is the answer; Formik is the alternative if a team already uses it.

6. State machines — when transitions are the complexity

The signal: you’ve added enough booleans to your component that you can describe an invalid combination. isLoading && isError && isSuccess is true at the same time. That’s a state machine waiting to be born.

import { setup, assign } from 'xstate';
import { useMachine } from '@xstate/react';

const checkoutMachine = setup({
  types: { context: {} as { error?: string } },
}).createMachine({
  id: 'checkout',
  initial: 'cart',
  states: {
    cart:       { on: { SUBMIT:  'paying' } },
    paying:     { on: { SUCCESS: 'done', FAILURE: 'failed' } },
    failed:     { on: { RETRY:   'paying' } },
    done:       { type: 'final' },
  },
});

function Checkout() {
  const [snapshot, send] = useMachine(checkoutMachine);
  // snapshot.matches('paying') etc.
}

The win isn’t the library — it’s the explicit transition table. The same shape can live in a useReducer for smaller cases. Reach for XState when you have modes (idle / live / paused / error) that share UI but differ in which events are valid.

7. React 19 primitives that span buckets

Three new tools that don’t replace the above — they layer on top.

useOptimistic — immediate UI, eventual consistency

The pattern: render the user’s edit instantly, then reconcile with the server’s response. Before useOptimistic this was a manual coordination dance with refs and rollback logic. Now:

const [comments, addComment] = useOptimistic(
  serverComments,                          // confirmed list from useQuery / props
  (state, optimistic: Comment) => [...state, { ...optimistic, pending: true }],
);

async function handleSubmit(body: string) {
  const draft = { id: crypto.randomUUID(), body };
  addComment(draft);                       // UI updates immediately
  await fetch('/api/comments', { method: 'POST', body: JSON.stringify(draft) });
  qc.invalidateQueries({ queryKey: ['comments'] });
}

The mental model: the optimistic value is transition-scoped and ephemeral. React resets it to the base state (serverComments) at the end of every commit, so on success the optimistic entry is replaced by the real one from the refetched list, and on failure (the awaited call throws) the transition unwinds and the optimistic entry simply disappears. No manual rollback code.

useActionState — action + state in one hook

For forms tied to server actions, useActionState keeps the result (errors, success message, the new value) alongside the action that produced it, without a separate useState:

type State = { error: string | null; ok: boolean };

const [state, action, pending] = useActionState<State, FormData>(
  async (_prev, formData) => signIn(formData),
  { error: null, ok: false },
);

<form action={action}>
  <input name="email" />
  {state.error && <p>{state.error}</p>}
  <button disabled={pending}>Sign in</button>
</form>

use(promise) — read async values with Suspense

A component can call use(promise) to read a value that may not be resolved yet; React suspends until it is. Combined with Server Components, you can pass a promise down through props and let the consumer pull when ready — but the consumer must live inside a <Suspense> boundary so React knows where to show the fallback:

// server.tsx
import { Suspense } from 'react';

export default function Page() {
  const userPromise = fetchUser();                   // not awaited here
  return (
    <Suspense fallback={<p>Loading user…</p>}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}

// client.tsx
'use client';
import { use } from 'react';

function UserCard({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);                     // suspends until resolved
  return <p>{user.name}</p>;
}

This is the path toward streaming UIs where the shell renders immediately and parts fill in as data arrives — without TanStack Query, without useEffect, without isLoading. The <Suspense> boundary is what makes it work; without one, the suspension propagates up to the nearest boundary or crashes the render.

A decision matrix

QuestionTool
One component, never read elsewhere?useState
One component, ≥3 related pieces or named transitions?useReducer
Several components, same tab, low update rate?Context (split per concern, memoise)
Many components, high update rate, fine-grained reads?Zustand or Jotai
Data from an API?TanStack Query (or SWR / RTK Query)
Should survive refresh / be shareable via URL?useSearchParams
State shared across multiple tabs of the same site?BroadcastChannel (or storage event) + a small store
A form with validation?React Hook Form + Zod
Invalid combinations of booleans exist?useReducer, then XState
Render immediately, reconcile later?useOptimistic
Form bound to a server action?useActionState
Async value, Suspense-aware?use(promise)

Three failure modes to call out

  1. Everything in one store. Sticking the basket, the theme, the API cache, the current modal, and the form draft into a single Redux store is what made the early-2020s React experience painful. Split by category, not just by feature.

  2. Server data in a client store. The biggest single mistake. You re-implement caching, deduplication, invalidation, and refetching — badly, by hand, with bugs. Use TanStack Query.

  3. State that should be in the URL, isn’t. Filters that disappear on refresh, tabs that don’t survive sharing a link, pagination that resets when you navigate back. Treat the URL as your first store.

TL;DR: “How do you manage state in React?” There are five kinds of state (local, shared client, server, URL, form), and one rare sixth (state machines). The right tool is different for each — useState / useReducer locally, Zustand or Jotai for shared client state, TanStack Query for server, useSearchParams for URL, React Hook Form for forms, XState when transitions themselves are the complexity. React 19 adds useOptimistic, useActionState, and use(promise) that compose with all of the above. The single biggest mistake is treating server data as client state — that’s what TanStack Query exists to fix.

Sources

← react