Skip to content

State Management in 2026: What to Use and When

A practical guide to choosing the right state management approach in modern React apps — useState, Zustand, React Query — and the reasoning behind each.

3 min read
Decision tree from “new state?” to four homes: local UI hooks, a TanStack Query server cache, a Zustand client store and URL parameters.

The State Management Paradox

The ecosystem has never had more state management options, yet most applications only need a handful of them. The mistake I see most often is reaching for a global state library before trying simpler solutions.

This is my decision framework, refined from working on dozens of React applications.

The Hierarchy of State

Before picking a tool, categorize what you're managing:

CategoryExamplesBest Tool
Local UI statemodal open, form input, hoveruseState / useReducer
Shared UI stateselected tab, sidebar openContext or Zustand
Server cacheuser data, product listTanStack Query / SWR
URL statefilters, pagination, searchURL params / nuqs
Persistent preferencestheme, languagelocalStorage + context

Most "we need Redux" decisions are actually "we need TanStack Query."

Layer 1: Local State — Start Here

Always start with local state. The vast majority of UI state belongs to a single component.

tstypescript
// ✅ This doesn't need to be global
function Modal({ onClose }: { onClose: () => void }) {
  const [step, setStep] = useState<"details" | "confirm" | "done">("details");
  const [formData, setFormData] = useState({ name: "", email: "" });
 
  return (
    <dialog>
      {step === "details" && (
        <DetailsForm data={formData} onChange={setFormData} onNext={() => setStep("confirm")} />
      )}
      {step === "confirm" && (
        <ConfirmStep data={formData} onBack={() => setStep("details")} onSubmit={handleSubmit} />
      )}
    </dialog>
  );
}

For complex local state with multiple sub-values, useReducer provides cleaner code than multiple useState calls.

Layer 2: Server State — TanStack Query

If you're storing API data in a global store, you're working against the grain. Server state has unique properties — it's async, it can go stale, it can be invalidated by other operations. TanStack Query is built for this.

tstypescript
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 
function UserProfile({ userId }: { userId: string }) {
  const queryClient = useQueryClient();
 
  // Automatic loading, error, and staleness handling
  const { data: user, isLoading } = useQuery({
    queryKey: ["users", userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
  });
 
  const updateUser = useMutation({
    mutationFn: (data: Partial<User>) => patchUser(userId, data),
    onSuccess: () => {
      // Invalidate and refetch after mutation
      queryClient.invalidateQueries({ queryKey: ["users", userId] });
    },
  });
 
  if (isLoading) return <Skeleton />;
 
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      updateUser.mutate({ name: e.target.name.value });
    }}>
      <input name="name" defaultValue={user.name} />
      <button type="submit" disabled={updateUser.isPending}>
        {updateUser.isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

This replaces: manual loading state, error state, caching logic, background refetching, optimistic updates — all from your global store.

Layer 3: URL State — Underused and Underrated

Filters, pagination, sort order, and search queries belong in the URL. It's free state that:

  • Survives page refreshes
  • Is shareable via link
  • Works with browser back/forward
  • Is indexable by search engines
tstypescript
import { useQueryStates } from "nuqs";
 
function ProductList() {
  const [params, setParams] = useQueryStates({
    page: parseAsInteger.withDefault(1),
    sort: parseAsString.withDefault("newest"),
    category: parseAsString.withDefault(""),
    minPrice: parseAsInteger.withDefault(0),
  });
 
  const { data } = useQuery({
    queryKey: ["products", params],
    queryFn: () => fetchProducts(params),
  });
 
  return (
    <div>
      <FilterBar filters={params} onChange={setParams} />
      <ProductGrid products={data?.items} />
      <Pagination
        page={params.page}
        total={data?.total}
        onChange={(page) => setParams({ page })}
      />
    </div>
  );
}

Layer 4: Client State — Zustand When You Need It

When you have state that is:

  • Shared across many distant components
  • Not server data
  • Not easily colocated

Zustand is the cleanest solution — minimal boilerplate, no providers needed, excellent TypeScript support.

tstypescript
import { create } from "zustand";
import { persist } from "zustand/middleware";
 
interface CartStore {
  items: CartItem[];
  addItem: (product: Product, quantity: number) => void;
  removeItem: (productId: string) => void;
  clearCart: () => void;
  total: () => number;
}
 
export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
 
      addItem: (product, quantity) =>
        set((state) => {
          const existing = state.items.find((i) => i.productId === product.id);
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.productId === product.id
                  ? { ...i, quantity: i.quantity + quantity }
                  : i,
              ),
            };
          }
          return {
            items: [
              ...state.items,
              { productId: product.id, product, quantity },
            ],
          };
        }),
 
      removeItem: (productId) =>
        set((state) => ({
          items: state.items.filter((i) => i.productId !== productId),
        })),
 
      clearCart: () => set({ items: [] }),
 
      total: () =>
        get().items.reduce(
          (sum, item) => sum + item.product.price * item.quantity,
          0,
        ),
    }),
    { name: "cart-storage" }, // Auto-persisted to localStorage
  ),
);

The Decision Tree

New state to manage?
│
├─ Is it UI state for one component?
│  └─ YES → useState / useReducer
│
├─ Is it data from a server?
│  └─ YES → TanStack Query
│
├─ Should it be in the URL?
│  └─ YES → URL params (nuqs)
│
├─ Is it shared across many components?
│  └─ YES → Zustand
│
└─ Is it a user preference that should persist?
   └─ YES → localStorage + small Zustand slice

The best state management system is the one you barely notice. If you're constantly wrestling with your state library, it's probably doing too much work that other layers could handle better.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX