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.

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:
| Category | Examples | Best Tool |
|---|---|---|
| Local UI state | modal open, form input, hover | useState / useReducer |
| Shared UI state | selected tab, sidebar open | Context or Zustand |
| Server cache | user data, product list | TanStack Query / SWR |
| URL state | filters, pagination, search | URL params / nuqs |
| Persistent preferences | theme, language | localStorage + 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.
// ✅ 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.
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
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.
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.


