← back to section

State is data that changes as the application runs and determines what the user sees. Whether a modal is open, what's typed in a field, the list of products from the server — all of this is state. Understanding where to store each kind is the first step toward keeping your code from turning into a tangle.

Local state

The majority of state is local: whether a dropdown is open, what's typed in a field, which tab is active. Such data only matters to one component. The tool for this is useState.

function SearchBox() {
  const [query, setQuery] = useState("");
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

The rule is simple: keep state as close as possible to where it is used. Don't lift it higher without a reason.

When there are many transitions and they are related (for example, a multi-step form), instead of a bunch of useState calls, use useReducer. It collects all transitions in one place:

type Action = { type: "next" } | { type: "prev" } | { type: "reset" };

function reducer(step: number, action: Action): number {
  switch (action.type) {
    case "next": return step + 1;
    case "prev": return step - 1;
    case "reset": return 0;
  }
}

function Wizard() {
  const [step, dispatch] = useReducer(reducer, 0);
  return (
    <div>
      <p>Step {step}</p>
      <button onClick={() => dispatch({ type: "next" })}>Next</button>
    </div>
  );
}

useReducer is convenient when the next value depends on the previous one and there are more than two or three transitions.

Shared state and context

Sometimes the same state is needed by multiple components. First try lifting it to the nearest common parent — that's the simplest approach. If the parent is far away and threading it through many levels is inconvenient, use context.

const ThemeContext = createContext<"light" | "dark">("light");

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}

function Button() {
  const theme = useContext(ThemeContext); // get the theme without prop drilling
  return <button className={theme}>Click</button>;
}

An important characteristic: when the context value changes, all components that read it re-render. So context works well for rarely changing data — theme, current user, UI language. For frequently changing data (mouse position, input text) it will cause unnecessary re-renders.

Server state is different

Here is the key distinction that simplifies a lot: data from the server is not application state — it is a temporary copy of server data on the client.

The product list, user profile, order history — all of this lives on the server. On the client you only have a copy that needs to be:

  • loaded when the page opens;
  • updated if the data is stale;
  • re-fetched after an error;
  • invalidated after a mutation.

If you store server data in useState, you have to write all of this by hand. This is a long-solved problem: for server state there is TanStack Query. It handles caching, retries, race conditions, and invalidation.

function ProductList() {
  const { data, isPending, isError } = useQuery({
    queryKey: ["products"],
    queryFn: () => fetch("/api/products").then((r) => r.json()),
  });

  if (isPending) return <p>Loading...</p>;
  if (isError) return <p>Failed to load</p>;
  return <ul>{data.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

Most of what beginners put into a global store is server data. Once that moves to TanStack Query, there is very little global state left.

When you need an external store

After server state moves to TanStack Query and local state to useState, there is not much left for a global store (Zustand, Redux):

  • cart contents before submission to the server — client-side state needed in different parts of the application;
  • complex multi-screen wizard state — when data from step 1 is needed at step 4;
  • global UI flags — for example, whether the sidebar is open.

The criterion: a store is justified when state is (1) client-side, not a server cache, (2) needed by unrelated components in different parts of the tree, and (3) changes frequently, so context isn't appropriate. If even one condition is not met — a store is likely premature.

Zustand is a simple starting point: minimal code, no boilerplate.

import { create } from "zustand";

interface CartStore {
  items: string[];
  add: (item: string) => void;
}

const useCart = create<CartStore>((set) => ({
  items: [],
  add: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function AddButton({ name }: { name: string }) {
  const add = useCart((s) => s.add);
  return <button onClick={() => add(name)}>Add</button>;
}

How to choose the right tool

A simple decision flowchart:

  1. State is needed by only one component → useState or useReducer.
  2. Needed by several components but changes rarely → context.
  3. It is data from the server → TanStack Query.
  4. Client-side, needed by many, changes frequently → a store (Zustand or Redux).

In short

  • State — data that determines what the user sees.
  • Keep state as close as possible to where it is used: start with useState.
  • useReducer — when there are many transitions and they are interconnected.
  • context — for rarely changing data needed deep in the tree (theme, user, locale).
  • Server data is a cache, not application state. Use TanStack Query for it.
  • A global store (Zustand, Redux) is justified only for client-side state that is needed by unrelated components and changes frequently.
  • Data fetching in React: TanStack Query — how requests, cache, and invalidation work.
  • React hooks — rules of hooks, useEffect, and custom hooks.
  • React components and props — how to build components and pass data down the tree.