March 26, 20266 min read

React State Management: What to Use and When

A practical breakdown of useState, useReducer, Context API, Redux, Zustand, and React Query. When each tool makes sense, when it doesn't, and how to avoid over-engineering state.

react state-management redux javascript frontend
Ad 336x280

State management is where React projects go to get complicated. Not because React's tools are bad -- they're actually good -- but because developers reach for heavy solutions before they need them. I've seen teams install Redux on day one for an app that has three pieces of shared state.

Here's how to think about it, starting from the simplest tool and scaling up only when you hit a real wall.

useState: Start Here

For state that belongs to one component, useState is all you need.

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

Form inputs, toggles, local UI state like "is this dropdown open" -- all useState. Don't overthink it.

Multiple related values? You can use multiple useState calls, and that's perfectly fine for two or three values. When it gets unwieldy, move to useReducer.

useReducer: When Local State Gets Complex

If you have state transitions that depend on previous state, or multiple values that change together, useReducer keeps things predictable:

type State = {
  items: string[];
  input: string;
  error: string | null;
};

type Action =
| { type: 'SET_INPUT'; payload: string }
| { type: 'ADD_ITEM' }
| { type: 'REMOVE_ITEM'; payload: number }
| { type: 'SET_ERROR'; payload: string };

function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_INPUT':
return { ...state, input: action.payload, error: null };
case 'ADD_ITEM':
if (!state.input.trim()) return { ...state, error: 'Cannot be empty' };
return { ...state, items: [...state.items, state.input], input: '', error: null };
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter((_, i) => i !== action.payload) };
case 'SET_ERROR':
return { ...state, error: action.payload };
default:
return state;
}
}

function TodoList() {
const [state, dispatch] = useReducer(reducer, {
items: [],
input: '',
error: null,
});

return (
<div>
<input
value={state.input}
onChange={e => dispatch({ type: 'SET_INPUT', payload: e.target.value })}
/>
<button onClick={() => dispatch({ type: 'ADD_ITEM' })}>Add</button>
{state.error && <p className="error">{state.error}</p>}
{state.items.map((item, i) => (
<div key={i}>
{item}
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: i })}>x</button>
</div>
))}
</div>
);
}

The reducer is a pure function. All your state logic is in one place. Easy to test, easy to reason about.

Context API: Sharing State Down the Tree

When multiple components need the same data, and prop drilling is getting painful (passing props through three or four intermediate components that don't use them), Context helps:

const ThemeContext = createContext<{ theme: string; toggle: () => void } | null>(null);

function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light');

return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}

function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
return ctx;
}

Context works great for things that change infrequently: theme, locale, auth status, feature flags. It's built into React. No extra library needed.

But Context is NOT a Redux replacement for frequently updating state. Here's why: every component that consumes a context re-renders when the context value changes. If you shove your entire app state into one context, changing any value re-renders every consumer. There's no selector mechanism to subscribe to just a slice.

For a theme toggle that changes rarely? Perfect. For a shopping cart that updates on every user interaction while 30 components display different parts of it? You'll feel the performance hit.

When You Actually Need a State Library

Signs you've outgrown Context:

  • Multiple unrelated pieces of state shared across many components
  • Frequent updates causing noticeable re-render cascading
  • Complex state derivation (computed values from multiple sources)
  • You need middleware-like behavior (logging, persistence, undo)
Zustand is my recommendation for most apps. Tiny API, no boilerplate, selectors built in:
import { create } from 'zustand';

interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}

const useCart = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set(state => ({ items: [...state.items, item] })),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id)
})),
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
}));

// Components subscribe to just what they need
function CartCount() {
const count = useCart(state => state.items.length);
return <span>{count} items</span>;
}

Only CartCount re-renders when items change, because the selector narrows the subscription. Redux does this too, but with more ceremony.

Redux Toolkit still makes sense for large teams with complex state, especially if you need Redux DevTools time-travel debugging or already have Redux middleware in place. Jotai is good for atomic state -- lots of independent pieces that compose.

Server State Is Different

This changed everything: most "global state" in React apps is actually server data. A list of users, product details, the current user's profile -- this isn't really client state, it's a cache of what the server knows.

TanStack Query (React Query) handles this brilliantly:
function UserList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
  });

if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading users</p>;
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

It handles caching, background refetching, deduplication, loading/error states, and cache invalidation. The amount of useState + useEffect code this replaces is staggering. Once you use it, going back feels wrong.

The Decision Framework

Here's how I think about it:

  1. Local UI state (toggle, form input, open/close) → useState
  2. Complex local state (form with validation, multi-step flow) → useReducer
  3. Shared state, changes rarely (theme, auth, locale) → Context API
  4. Shared state, changes often, many consumers → Zustand or Redux Toolkit
  5. Server data (API responses, lists, entities) → TanStack Query
Start at the top. Move down only when the simpler option is causing real problems, not hypothetical ones.

If you want to practice these patterns hands-on -- building components with different state strategies and seeing the trade-offs yourself -- check out the interactive React exercises on CodeUp.

Ad 728x90