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.
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
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
useStateinside 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
- 01URLWhat must survive a reload and go into a link
- 02Server data cacheA copy of server data with a freshness period
- 03Global client storeA small amount of state changed from many places
- 04ContextDelivering a value deep down the tree
- 05Local stateEverything else — next to the component
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:
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.
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>
);
}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={…}>.
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;
}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>;
}- When the provider value changes (compared with
Object.is), React re-renders every component that reads this context.memodoes not prevent it. - If the
valueobject is rebuilt on every render, consumers re-render even when the data has not changed. That is whyaddis wrapped inuseCallbackand the object inuseMemo; 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.
npm install zustand @tanstack/react-queryimport { 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);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>;
}add actionThe 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:
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 })));- 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)inbeforeEach.
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.
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
});
}queryFn depends on goes into the keyimport { 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>,
);QueryClient per appimport { 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>
);
}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.
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();
});QueryClient per test and retry: false, as the TanStack Query testing guide recommendsState 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.
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);
}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>
);
}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
useStateor 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:
useMemoanduseCallbackif the provider re-renders often - Zustand selectors return a primitive, an existing reference, or use
useShallow - The query key contains every variable
queryFndepends on - URL values are checked against a list of allowed values
$ 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)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.