How to buildOctober 11, 20267 min read
ReactStep 6 of 8

State management: local, Context, Zustand and server state

In short

Start with useState in the component that needs the value, and lift it to a common parent when siblings need it too. Context is a way to deliver a value deep down without props, not a store; Zustand is for client state that is read and changed from distant parts of the app; keep server data in the TanStack Query cache and filters and pages in the URL. A global store should not hold what already lives on the server or in the address bar.

ComparisonLevel: IntermediateState Management

After this you will be able to

  • Split a screen's state into kinds: local UI, shared client, server, URL and form
  • Pick a tool for each kind — useState, lifting, Context, Zustand, TanStack Query or URL parameters — and justify the choice
  • Avoid typical traps: extra Context re-renders, a Zustand selector that returns a new object every time, and server data in a global store

Verified with

React 19.3Zustand 5.0TanStack Query 5.104TypeScript 6.0Vite 8.3Vitest 5.0 + Testing Library 16.3

Checked against docs: October 11, 2026

Contents
  1. 01Kinds of state
  2. 02Local state and lifting it up
  3. 03Context: delivery, not a store
  4. 04Zustand: a minimal global store
  5. 05Server state: TanStack Query and the cache
  6. 06State in the URL: filters and pagination
  7. 07Decision table and common mistakes

Kinds of state

"Which state manager should we use?" is usually asked too early. First figure out what exactly is being stored: different kinds of state have different owners, lifetimes and problems. Take a coffee shop menu screen: a category filter, a list of drinks from the server, a cart in the header and an order form.

Local UI

Example on the menu screen
Whether a dropdown is open, the filter value in one block
Owner
One component
Where to put it
useState inside it

Shared client

Example on the menu screen
The cart seen by the header and the cards
Owner
The app in the browser
Where to put it
Lifting state → Context → Zustand

Server

Example on the menu screen
The list of drinks and prices
Owner
The server; the browser holds a copy that goes stale
Where to put it
The TanStack Query cache

URL

Example on the menu screen
Selected category, page number
Owner
The address bar: it can be shared as a link
Where to put it
Search parameters (?category=tea)

Form

Example on the menu screen
Name and phone in the order
Owner
The form until submission
Where to put it
Form fields or React Hook Form
  1. 01URLWhat must survive a reload and go into a link
  2. 02Server data cacheA copy of server data with a freshness period
  3. 03Global client storeA small amount of state changed from many places
  4. 04ContextDelivering a value deep down the tree
  5. 05Local stateEverything else — next to the component
Layers of state: the lower, the closer to the component and the simpler.

We covered forms in detail in "Forms and validation in React", so this article deals with the other four kinds. All examples below implement the same "filter + cart" feature in different ways; the data is shared:

src/shared/products.ts
export type Category = "all" | "coffee" | "tea";
export type Product = { id: string; name: string; category: Exclude<Category, "all">; price: number };

export const PRODUCTS: Product[] = [
  { id: "latte", name: "Latte", category: "coffee", price: 1500 },
  { id: "espresso", name: "Espresso", category: "coffee", price: 900 },
  { id: "matcha", name: "Matcha", category: "tea", price: 1700 },
];

export const byCategory = (products: Product[], category: Category) =>
  category === "all" ? products : products.filter((p) => p.category === category);

Local state and lifting it up

The React docs offer a simple rule: if two components must change together, remove the state from both, move it to their closest common parent and pass it down via props. This is called lifting state up. The filter is needed only by the list, so it stays there. The cart is shown by the header and filled by the list, so it moves up to App.

src/a-lifted/App.tsx
import { useState } from "react";
import { PRODUCTS, byCategory, type Category } from "../shared/products.ts";

/* the cart is needed by the header and the list → it lives in their closest common parent */
export default function App() {
  const [cart, setCart] = useState<string[]>([]);
  return (
    <>
      <header>Cart: {cart.length}</header>
      <ProductList onAdd={(id) => setCart((ids) => [...ids, id])} />
    </>
  );
}

/* the filter is needed only here → local state */
function ProductList({ onAdd }: { onAdd: (id: string) => void }) {
  const [category, setCategory] = useState<Category>("all");
  return (
    <section>
      <select aria-label="Category" value={category} onChange={(e) => setCategory(e.target.value as Category)}>
        <option value="all">All</option>
        <option value="coffee">Coffee</option>
        <option value="tea">Tea</option>
      </select>
      <ul>
        {byCategory(PRODUCTS, category).map((p) => (
          <li key={p.id}>
            {p.name} <button onClick={() => onAdd(p.id)}>Add {p.name}</button>
          </li>
        ))}
      </ul>
    </section>
  );
}
Option 1: a local filter, the cart lifted to the common parent

This lasts longer than you might think. Lifting stops being convenient when a value passes through several intermediate components that do not need it (prop drilling). Even then React advises against rushing to Context: first try passing props explicitly — it shows which components use which data — or extract components and pass JSX through children to reduce the number of intermediate levels.

Context: delivery, not a store

Context lets a component pass a value to the whole tree below it without props. Context itself stores nothing — useState or useReducer in the provider stores the data, and Context only delivers it. The React docs list typical uses: theming, the current account, routing and managing state together with a reducer. In React 19 the context itself is the provider: <CartContext value={…}>.

src/b-context/cart-context.tsx
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";

type CartValue = { ids: string[]; add: (id: string) => void };

const CartContext = createContext<CartValue | null>(null);

export function CartProvider({ children }: { children: ReactNode }) {
  const [ids, setIds] = useState<string[]>([]);
  const add = useCallback((id: string) => setIds((prev) => [...prev, id]), []);
  const value = useMemo(() => ({ ids, add }), [ids, add]);
  return <CartContext value={value}>{children}</CartContext>;
}

export function useCart() {
  const cart = useContext(CartContext);
  if (!cart) throw new Error("useCart must be used inside <CartProvider>");
  return cart;
}
The cart provider and a hook that fails clearly outside the provider
src/b-context/App.tsx
import { CartProvider, useCart } from "./cart-context.tsx";
import { PRODUCTS } from "../shared/products.ts";

export default function App() {
  return (
    <CartProvider>
      <Header />
      <main>
        {PRODUCTS.map((p) => (
          <AddButton key={p.id} id={p.id} name={p.name} />
        ))}
      </main>
    </CartProvider>
  );
}

function Header() {
  const { ids } = useCart();
  return <header>Cart: {ids.length}</header>;
}

function AddButton({ id, name }: { id: string; name: string }) {
  const { add } = useCart();
  return <button onClick={() => add(id)}>Add {name}</button>;
}
Option 2: the header and buttons read the cart from context, no props
  • When the provider value changes (compared with Object.is), React re-renders every component that reads this context. memo does not prevent it.
  • If the value object is rebuilt on every render, consumers re-render even when the data has not changed. That is why add is wrapped in useCallback and the object in useMemo; the React docs describe this as an optimization that not every app will notice.
  • You cannot subscribe to part of a context: a component with a single "Add" button re-renders on every cart change. For rarely changing values (theme, language, user) this does not matter; for frequently changing ones it is a reason to look at a store.

Zustand: a minimal global store

Zustand is a small library in which a store is a hook. State and actions are described by one function, no provider is needed, and a component subscribes to part of the store with a selector and re-renders only when the selector result changes.

bash
npm install zustand @tanstack/react-query
src/c-zustand/cart-store.ts
import { create } from "zustand";

type CartState = {
  qty: Record<string, number>;
  add: (id: string) => void;
  clear: () => void;
};

export const useCartStore = create<CartState>()((set) => ({
  qty: {},
  add: (id) => set((state) => ({ qty: { ...state.qty, [id]: (state.qty[id] ?? 0) + 1 } })),
  clear: () => set({ qty: {} }),
}));

export const selectCount = (state: CartState) =>
  Object.values(state.qty).reduce((sum, n) => sum + n, 0);
The cart store: quantities per product and two actions
src/c-zustand/App.tsx
import { selectCount, useCartStore } from "./cart-store.ts";
import { PRODUCTS } from "../shared/products.ts";

export default function App() {
  return (
    <>
      <Header />
      <main>
        {PRODUCTS.map((p) => (
          <AddButton key={p.id} id={p.id} name={p.name} />
        ))}
      </main>
    </>
  );
}

function Header() {
  const count = useCartStore(selectCount); // re-renders only when the number changes
  const clear = useCartStore((state) => state.clear);
  return (
    <header>
      Cart: {count} <button onClick={clear}>Clear</button>
    </header>
  );
}

function AddButton({ id, name }: { id: string; name: string }) {
  const add = useCartStore((state) => state.add);
  return <button onClick={() => add(id)}>Add {name}</button>;
}
Option 3: the header subscribes to a number, buttons only to the add action

The main trap is a selector that returns a new object on every call. We tested it with React 19 and Zustand 5: the component crashes with "Maximum update depth exceeded", and before that React warns that the result of getSnapshot should be cached. The fix from the Zustand docs is useShallow:

TSX
import { useShallow } from "zustand/react/shallow";

// ✗ a new object on every call: React 19 + Zustand 5 fail with
//   "Maximum update depth exceeded"
const { count, clear } = useCartStore((s) => ({ count: selectCount(s), clear: s.clear }));

// ✓ useShallow compares the fields of the object, not the reference
const { count, clear } = useCartStore(useShallow((s) => ({ count: selectCount(s), clear: s.clear })));
A fragment from a test, not a standalone component
  • The store works outside React too: useCartStore.getState().add("latte") — handy in tests and in handlers outside components.
  • The store is a module variable shared by everything that imports it. The Zustand docs for Next.js warn that on the server such a store is shared across requests, so you create it per request and pass it through Context, and Server Components must not read from or write to it.
  • Reset the store in tests: useCartStore.setState(useCartStore.getInitialState(), true) in beforeEach.

Server state: TanStack Query and the cache

The list of drinks is not app state but a copy of someone else's data. The TanStack Query docs list how server state differs from client state: it is persisted remotely, changes without your knowledge and can become out of date. Hence the tasks that useState + useEffect handle poorly: caching, deduplicating identical requests, background updates, knowing when data is stale, and cleaning up memory.

src/d-query/products-api.ts
import { useQuery } from "@tanstack/react-query";
import type { Category, Product } from "../shared/products.ts";

export async function fetchProducts(category: Category): Promise<Product[]> {
  const res = await fetch(`/api/products?category=${category}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

export function useProducts(category: Category) {
  return useQuery({
    queryKey: ["products", category], // every variable used by queryFn goes into the key
    queryFn: () => fetchProducts(category),
    staleTime: 60_000, // the menu rarely changes: treat it as fresh for a minute
  });
}
The request and the hook: everything queryFn depends on goes into the key
src/d-query/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Catalog } from "./Catalog.tsx";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <Catalog />
    </QueryClientProvider>
  </StrictMode>,
);
One QueryClient per app
src/d-query/Catalog.tsx
import { useState } from "react";
import { useProducts } from "./products-api.ts";
import type { Category } from "../shared/products.ts";

export function Catalog() {
  const [category, setCategory] = useState<Category>("all");
  const { data, isPending, isError, refetch } = useProducts(category);

  return (
    <section>
      <select aria-label="Category" value={category} onChange={(e) => setCategory(e.target.value as Category)}>
        <option value="all">All</option>
        <option value="coffee">Coffee</option>
        <option value="tea">Tea</option>
      </select>
      {isPending && <p>Loading…</p>}
      {isError && (
        <p role="alert">
          Could not load the menu. <button onClick={() => refetch()}>Retry</button>
        </p>
      )}
      <ul>
        {data?.map((p) => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </section>
  );
}
Loading, an error with retry, and a filter that changes the query key

Know the defaults from the Important Defaults page. Cached data is considered stale immediately (staleTime: 0) and is refetched in the background when a new query instance mounts, the window regains focus or the network reconnects. A failed query is retried 3 times with exponential backoff before the error is shown. Inactive data is garbage collected after 5 minutes. For a menu that rarely changes we set staleTime to one minute — and the test below checks that going back to the "All" category makes no second request.

src/d-query/Catalog.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, expect, it, vi } from "vitest";
import { Catalog } from "./Catalog.tsx";
import { PRODUCTS, byCategory, type Category } from "../shared/products.ts";

function renderWithClient() {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); // a fresh cache per test
  return render(
    <QueryClientProvider client={client}>
      <Catalog />
    </QueryClientProvider>,
  );
}

function mockApi() {
  const fetchMock = vi.fn(async (url: string) => {
    const category = new URL(url, "http://test").searchParams.get("category") as Category;
    return Response.json(byCategory(PRODUCTS, category));
  });
  vi.stubGlobal("fetch", fetchMock);
  return fetchMock;
}

afterEach(() => vi.unstubAllGlobals());

it("loads, filters through the query key and reuses the cache", async () => {
  const user = userEvent.setup();
  const fetchMock = mockApi();
  renderWithClient();

  expect(screen.getByText("Loading…")).toBeInTheDocument();
  expect(await screen.findByText("Espresso")).toBeInTheDocument();

  await user.selectOptions(screen.getByLabelText("Category"), "tea");
  expect(await screen.findByText("Matcha")).toBeInTheDocument();
  expect(screen.queryByText("Espresso")).not.toBeInTheDocument();

  await user.selectOptions(screen.getByLabelText("Category"), "all");
  expect(await screen.findByText("Espresso")).toBeInTheDocument();
  expect(fetchMock).toHaveBeenCalledTimes(2); // "all" came from the cache: staleTime has not passed
});

it("shows an error and retries on demand", async () => {
  const user = userEvent.setup();
  vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 500 })));
  renderWithClient();

  expect(await screen.findByRole("alert")).toHaveTextContent("Could not load the menu");

  mockApi();
  await user.click(screen.getByRole("button", { name: "Retry" }));
  expect(await screen.findByText("Latte")).toBeInTheDocument();
});
A fresh QueryClient per test and retry: false, as the TanStack Query testing guide recommends

State in the URL: filters and pagination

If users expect the same filter, sort order or page after a reload or from a shared link, that is address bar state. Keeping it in useState or a store means losing it on refresh. In the browser, URLSearchParams handles search parameters, and history.replaceState changes the address without a reload and without a new history entry.

src/e-url/category-param.ts
import type { Category } from "../shared/products.ts";

const CATEGORIES: Category[] = ["all", "coffee", "tea"];

export function readCategory(): Category {
  const value = new URLSearchParams(window.location.search).get("category");
  return CATEGORIES.find((c) => c === value) ?? "all";
}

export function writeCategory(category: Category) {
  const params = new URLSearchParams(window.location.search);
  if (category === "all") params.delete("category");
  else params.set("category", category);
  const query = params.toString();
  window.history.replaceState(null, "", query ? `?${query}` : window.location.pathname);
}
Reading and writing the parameter; an unknown value becomes "all"
src/e-url/UrlFilter.tsx
import { useState } from "react";
import { readCategory, writeCategory } from "./category-param.ts";
import type { Category } from "../shared/products.ts";

export function UrlFilter() {
  const [category, setCategory] = useState<Category>(readCategory);

  function change(next: Category) {
    setCategory(next);
    writeCategory(next); // a shared link now opens the same filter
  }

  return (
    <select aria-label="Category" value={category} onChange={(e) => change(e.target.value as Category)}>
      <option value="all">All</option>
      <option value="coffee">Coffee</option>
      <option value="tea">Tea</option>
    </select>
  );
}
The filter takes its initial value from the URL and writes the new one in the handler, without an effect

URL values come from the user, so they are checked against a list of allowed values. Other parameters (here page) are preserved. Next.js and other frameworks with a router have their own hooks for this — we cover them in the App Router lesson; the principle is the same.

Decision table and common mistakes

One component needs the value

Tool
useState / useReducer
Why
The shortest path; nothing leaks out

Siblings need the value

Tool
Lift to the common parent
Why
Data flow is explicit and visible in props

Rarely changes, needed deep down: theme, language, user

Tool
Context
Why
No props through every level; extra re-renders do not hurt

Changes often and is needed in different parts: cart, draft, player

Tool
Zustand (or lifting + Context in a small app)
Why
Selectors: a component re-renders only because of its own slice

Data from the server

Tool
TanStack Query
Why
Cache, background updates, retries and loading states

Must survive a reload and go into a link

Tool
URL parameters
Why
The address is the only place both a link and a reload can see

Form fields before submit

Tool
Form fields or React Hook Form
Why
Uncontrolled fields do not re-render the whole form on every keystroke
  • Server data is not copied into useState or a store "to keep it handy" — otherwise there are two versions of the truth
  • Nothing is duplicated: what can be computed from other state (the cart total) is computed during render, not stored
  • The Context provider value is stable: useMemo and useCallback if the provider re-renders often
  • Zustand selectors return a primitive, an existing reference, or use useShallow
  • The query key contains every variable queryFn depends on
  • URL values are checked against a list of allowed values
terminal
$ npx vitest run --reporter=verbose
 ✓ src/e-url/UrlFilter.test.tsx > reads the filter from the URL and writes it back
 ✓ src/e-url/UrlFilter.test.tsx > ignores unknown values
 ✓ src/cart.test.tsx > cart via lifted state > the header sees what the list adds
 ✓ src/d-query/Catalog.test.tsx > loads, filters through the query key and reuses the cache
 ✓ src/d-query/Catalog.test.tsx > shows an error and retries on demand
 ✓ src/cart.test.tsx > cart via context > the header sees what the list adds
 ✓ src/cart.test.tsx > cart via zustand > the header sees what the list adds
 ✓ src/cart.test.tsx > local filter state does not leak into the cart
 ✓ src/cart.test.tsx > useCart outside the provider fails loudly
 ✓ src/cart.test.tsx > zustand store works outside React too

 Test Files  3 passed (3)
      Tests  10 passed (10)
Real Vitest output (timings omitted): all three cart options pass the same test
How this was verified7 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.