← All posts

React State Management Best Practices (2026)

By Harshit Dixit · · 10 min read

“Which state management library should we use?” is usually the wrong first question. Most React codebases that feel tangled don't have the wrong library — they have state in the wrong place: server data copied into a global store, URL state held in useState, values synced between components with useEffect. Once each piece of state lives where it belongs, the library question mostly answers itself, and there is usually much less global state than anyone expected.

Step 1: sort your state into five kinds

KindExamplesWhere it belongs
Server stateProducts, orders, the current user's profileServer Components, or a server-cache library such as TanStack Query
URL stateFilters, search query, sort order, page number, selected tabThe URL — search params or the route
Form stateField values, validation errors, submitting statusThe form — native inputs, a form library, or React 19 actions
Local UI stateIs this dropdown open, which accordion panel is expandeduseState in the component that uses it
Global client stateTheme, a multi-step wizard, a cart before checkout, an editor's documentContext, Zustand, Redux Toolkit or similar

The rest of this guide is about each row, because the best practices are different for each.

Server state is a cache, not state

Data that lives in your database is owned by the server. What the client holds is a copy, and a copy has cache problems — when is it stale, when to refetch, what happens when two components ask for it at once, how to update it optimistically and roll back on failure. Putting it in Redux or Zustand means writing all of that by hand, and it's where a lot of global-store complexity comes from.

Two better options:

  • Server Components(Next.js App Router and other RSC frameworks) — fetch on the server and render. There's no client-side copy to manage at all. After a mutation, revalidate the path or tag and the server re-renders.
  • TanStack Query (or SWR) for data that has to be fetched and refreshed on the client — dashboards, infinite lists, anything polling. It handles caching, deduplication, background refetching and optimistic updates.

Once server data is out of the global store, it often turns out the store had very little else in it.

Put shareable state in the URL

If a user would reasonably expect to bookmark it, share it, or get it back with the back button, it belongs in the URL. Filters, search, sort, pagination and selected tabs all qualify. Keeping them in useStatemeans the back button doesn't work, a shared link loses the view, and a refresh resets everything.

"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";

export function SortSelect() {
  const params = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();

  function onChange(e) {
    const next = new URLSearchParams(params);
    next.set("sort", e.target.value);
    router.replace(`${pathname}?${next}`, { scroll: false });
  }

  return (
    <select value={params.get("sort") ?? "newest"} onChange={onChange}>
      <option value="newest">Newest</option>
      <option value="price">Price</option>
    </select>
  );
}

In an App Router page, the server can read the same searchParams and render the filtered list directly, so the first load is already correct and indexable.

Let forms own form state

A form whose every keystroke goes through a global store re-renders far more than it needs to. Keep it local. For simple forms, uncontrolled inputs read with FormDataon submit are often enough. React 19's useActionState handles the submit–pending–result cycle, and useOptimistic covers showing a result before the server confirms it. For large forms with complex validation, a dedicated library such as React Hook Form keeps re-renders to the fields that change.

Keep local state local

The default for any new piece of state should be useStatein the lowest component that needs it. Lift it up only when a sibling genuinely needs it, and only as far as their closest common parent. State that's higher than necessary re-renders more of the tree than necessary.

Derive, don't sync

The most common React state bug is keeping two pieces of state in sync with an effect:

// avoid: two sources of truth, one render behind
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
useEffect(() => {
  setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);

// prefer: compute it during render
const [items, setItems] = useState([]);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);

If a value can be calculated from props or other state, calculate it. If the calculation is genuinely expensive, useMemo it — and with the React Compiler, much of that memoisation happens automatically.

Global client state: less than you think, chosen deliberately

After server, URL, form and local state have gone where they belong, what's left is usually small: theme, auth session details already on the client, a cart, a wizard, a complex editor. For that, pick by size and update frequency.

Context: for values that rarely change

Context is dependency injection, not a state manager. Every component that reads a context re-renders when its value changes, so it's ideal for theme, locale or the current user, and a poor fit for anything that updates on every keystroke. If you do use it for changing state, split it: separate contexts for values that change independently, and separate the state from the setterso components that only dispatch don't re-render.

const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);

export function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, initialCart);
  return (
    <CartDispatchContext value={dispatch}>
      <CartStateContext value={cart}>{children}</CartStateContext>
    </CartDispatchContext>
  );
}

Zustand: small stores with selective subscriptions

Zustand is a good default for modest global state. Components subscribe with a selector and re-render only when the selected slice changes — which fixes Context's main weakness with very little code.

It's what we used for client state on a savings-account rewards program I led at Acko — a React and Next.js frontend in front of Node.js microservices, which went on to add more than 80,000 newly engaged users. The principle that kept it manageable is the one in this guide: data that belongs to the server stays with the server, and the client store holds only what is genuinely shared on the client.

import { create } from "zustand";

export const useCart = create((set) => ({
  items: [],
  add: (item) => set((s) => ({ items: [...s.items, item] })),
  clear: () => set({ items: [] }),
}));

// re-renders only when the count changes, not on every cart update
const count = useCart((s) => s.items.length);

Redux Toolkit: when you need the structure

Redux earns its extra ceremony in large applications with many developers, complex cross-cutting updates, or a need for strict traceability — every change is an action you can log, replay and inspect in DevTools. Use Redux Toolkit rather than hand-written reducers, and RTK Query if you're already on Redux and need a server-cache layer. For a small team building a small-to-medium app, it's usually more than you need.

Performance habits that matter

  • Subscribe to the smallest slice of state a component needs — a selector, not the whole store.
  • Keep frequently changing state (a text input, a drag position) as low in the tree as possible.
  • Don't create new objects in a context value or selector on every render unless they're memoised; a new object is a “change” to every subscriber.
  • Use startTransition for updates that trigger expensive re-renders, so urgent updates like typing stay responsive.
  • Measure with the React DevTools Profiler before optimising — guesses about re-renders are often wrong.

The short version

  1. Server data goes in Server Components or a server-cache library, not a global store.
  2. Anything shareable or bookmarkable goes in the URL.
  3. Forms own their own state.
  4. Everything else starts as local useState and is lifted only as far as needed.
  5. Derive values during render instead of syncing them with effects.
  6. For what's genuinely global: Context for slow-changing values, Zustand for most apps, Redux Toolkit when the scale and team justify it.

If your React codebase has outgrown its state management and you want a senior engineer to untangle it, that's what a freelance React developer engagement is for.

// free, no strings

Book a free consultation

30 minutes, no cost, no obligation — pick a slot that works for you.

  • ›Sign in with Google to book instantly
  • ›Or just email/WhatsApp if you'd rather
  • ›We'll follow up with a free draft, not a sales pitch