Skip to content
Back to blog

React state management in 2026: how to choose without overthinking it

“Which state library should we use?” is usually the wrong first question. Most of the state in a typical app is not one kind of thing, and the pain starts when you treat it as one. Reviewing React code, the fix is rarely a new library. It is putting each piece of state in the right place.

Here is the order I work through, from the simplest tool to the heaviest.

Start by naming the kind of state

Before choosing a tool, I ask what the state is.

  • Local UI state: an open menu, an input draft, a selected tab.
  • Shared client state: a theme, a cart, a multi-step form used by several distant components.
  • Server state: data that lives on a backend and that you only hold a copy of: users, posts, orders.
  • URL state: filters, page numbers, the selected item. The router already owns this.

Server state is the one people get wrong most often. A copy of server data can go stale, needs loading and error handling, and can be refetched. That is a different problem from “which modal is open”.

useState: the default

If one component owns the value, useState is the answer. No setup, no indirection.

SearchBox.tsx
function SearchBox() {
  const [query, setQuery] = useState(""); 

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

When two siblings need the same value, lift it to their closest common parent and pass it down. That is still useState, just placed higher. It handles more cases than people expect.

Colocation: keep state close to where it is used

The most useful habit is moving state down, not up. Global state feels convenient, but every consumer is now coupled to it.

A common smell is a high component holding state that only one small child cares about. Every keystroke re-renders the whole subtree.

Broken
function Page() {
  const [query, setQuery] = useState(""); 

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} /> // [!code --]
      <ExpensiveChart />
    </>
  );
}

Move the state into a small component that owns it.

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

function Page() {
  return (
    <>
      <SearchBox />
      <ExpensiveChart />
    </>
  );
}

Now typing only re-renders SearchBox. No memoization needed, which fits the rule from the memoization post: try restructuring before you reach for memo.

useReducer: when updates have rules

useReducer is not “Redux in a hook”. It is for state where the next value depends on a named event and several fields change together.

checkoutReducer.ts
type State = { step: "cart" | "address" | "pay"; address: string; error: string | null };

type Action =
  | { type: "next" }
  | { type: "back" }
  | { type: "setAddress"; address: string }
  | { type: "fail"; error: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "next":
      return { ...state, step: state.step === "cart" ? "address" : "pay", error: null }; 
    case "back":
      return { ...state, step: state.step === "pay" ? "address" : "cart" };
    case "setAddress":
      return { ...state, address: action.address };
    case "fail":
      return { ...state, error: action.error };
  }
}

I switch from useState when I catch myself writing three setX calls in a row that must stay consistent, or when impossible combinations become possible. The reducer is a plain function, so it is easy to test without rendering anything.

Context: dependency injection, not a store

Context lets a deep component read a value without passing props through every layer. It is great for things that rarely change: theme, locale, the current user, a router instance.

The trap is thinking of it as a state manager. When the context value changes, every component that reads it re-renders. React does not let a consumer subscribe to only part of the value.

Broken: new object every render
const AppContext = createContext<{ user: User; cart: Cart } | null>(null);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User>(guest);
  const [cart, setCart] = useState<Cart>(emptyCart);

  return (
    <AppContext.Provider value={{ user, cart, setUser, setCart }}>
      {children}
    </AppContext.Provider>
  );
}

Two problems here. The value object is created on every render of the provider, so every consumer re-renders even if nothing changed. And user and cart share one context, so a component that only reads user still re-renders when the cart changes.

The fixes are boring and work well.

Fixed: split and stabilize
const UserContext = createContext<User | null>(null); 
const CartContext = createContext<Cart | null>(null); 

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user] = useState<User>(guest);
  const [cart, setCart] = useState<Cart>(emptyCart);

  const cartValue = useMemo(() => ({ cart, setCart }), [cart]); 

  return (
    <UserContext.Provider value={user}>
      <CartContext.Provider value={cartValue}>{children}</CartContext.Provider>
    </UserContext.Provider>
  );
}

Split contexts by how often they change, and memoize the value object when it is built inline. Also remember that children passed from above keep their identity, so they are not re-rendered just because the provider’s own state changed. Only the components that actually read the context are.

Zustand: when a store is justified

A small external store such as Zustand gives you what Context cannot: components subscribe to a slice, and only re-render when that slice changes.

cartStore.ts
import { create } from "zustand";

type CartState = {
  items: { id: string; qty: number }[];
  add: (id: string) => void;
};

export const useCart = create<CartState>((set) => ({
  items: [],
  add: (id) =>
    set((state) => ({ items: [...state.items, { id, qty: 1 }] })),
}));

// In a component
function CartBadge() {
  const count = useCart((state) => state.items.length); 
  return <span>{count}</span>;
}

CartBadge re-renders only when the item count changes, not when some other field in the store does. There is no provider to wrap around the app, and the store is reachable outside React too, which helps with things like analytics or a socket handler.

I reach for a store when all of these are true:

  • The state is genuinely shared across distant parts of the tree.
  • It changes often enough that Context would cause wasteful re-renders.
  • Lifting state up has turned into prop drilling that hurts.
  • It is client state, not a copy of server data.

If one of those is missing, I stay with useState or Context. A store is not wrong, it is just one more thing to learn, test and keep in sync. The same applies to Redux Toolkit and Jotai: they are valid choices, and the decision process is the same.

Server state: TanStack Query

This is the category that most “global state” really belongs to. If you put API responses into a store with loading, error and data fields, you have written a worse version of a data library.

Posts.tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

function Posts() {
  const queryClient = useQueryClient();

  const { data, isPending, error } = useQuery({ 
    queryKey: ["posts"], 
    queryFn: () => fetch("/api/posts").then((res) => res.json()), 
  }); 

  const addPost = useMutation({
    mutationFn: (title: string) =>
      fetch("/api/posts", { method: "POST", body: JSON.stringify({ title }) }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }), 
  });

  if (isPending) return <p>Loading...</p>;
  if (error) return <p>Something went wrong.</p>;

  return (
    <ul>
      {data.map((post: { id: string; title: string }) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

You get caching by key, deduplicated requests, background refetching, loading and error states, and invalidation after a mutation. Two components calling useQuery with the same key share one request and one cache entry, so there is no need to put the data in a context to share it.

If your framework fetches on the server, such as Next.js with Server Components, a lot of this moves out of the client entirely. In that case the client only needs a query library for the parts that stay interactive.

Putting it together

Where does this state go?
  1. Is it in the URL?router / search params
  2. Is it server data?TanStack Query or the server
  3. One component owns it?useState, colocated
  4. Complex update rules?useReducer
  5. Rarely changes, many readers?Context
  6. Shared and changes oftena store like Zustand

Walk down the list. Stop at the first yes.

The tools also combine. A page can use useState for a dropdown, TanStack Query for its data, Context for the theme and a store for a cart, and that is fine, because each one has a job.

Interview answer in 30 seconds

“I first classify the state. URL state lives in the router and server state in a query library like TanStack Query or on the server. Local UI state stays in useState, colocated as low in the tree as it can go, and useReducer helps when updates follow rules. Context is for rarely changing values like theme, because every consumer re-renders when its value changes, so I split contexts and stabilize the value. I add a store like Zustand only when client state is shared widely, changes often, and consumers need to subscribe to slices.”

Key takeaways

  • Classify the state first: local, shared client, server or URL. Each has a best home.
  • Default to useState and keep it as close to its users as possible.
  • Use useReducer when several fields change together under named events.
  • Context re-renders every consumer when its value changes. Split it and stabilize the value, or avoid it for fast-changing data.
  • Server data belongs in TanStack Query or on the server, not in a client store.
  • A store such as Zustand is justified for widely shared, frequently changing client state.