Master React state management — when to use local state, when to lift state, when Context is the right tool, and when to reach for Zustand or Redux. Plus server state (React Query) and URL state.
React state management is a spectrum, not a single decision. At one end is useState for state owned by a single component. Next comes lifting state up for siblings that share it. Then Context for ambient data many components read. Then external stores (Redux, Zustand) for global, frequently-changing state with selective subscriptions. At the far end is server state (React Query) for data that lives on the server. The rule: keep state as local as possible, and only promote it up the chain when you have a concrete reason to.
Before choosing a tool, identify which category the state belongs to:
Use useState for simple values (a string, boolean, or number) and useReducer when the next state depends on the previous state or multiple values must change together:
// useState — simple toggle
const [isOpen, setIsOpen] = useState(false);
// useReducer — multiple related state transitions
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
dispatch({ type: 'ADD_ITEM', payload: product });
dispatch({ type: 'REMOVE_ITEM', payload: productId });
Rule: if state only matters to one component (a dropdown open/close, an active tab, a form field value), keep it local. Moving state out is premature complexity.
When two sibling components need to share state, lift it to their closest common ancestor. The ancestor owns the state and distributes it as props:
// Parent owns the state — both children are controlled
function SearchPage() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
<SearchResults query={query} />
</>
);
}
This is the correct solution for most shared state situations — no library needed.
Context is right for data that is:
const ThemeContext = React.createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<AppContent />
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</ThemeContext.Provider>
);
}
Context's weakness: all consumers re-render when the value changes — even if only one field in a large context object changed. For frequently-updated state, this is a performance bottleneck.
External stores solve Context's re-render problem with selective subscriptions — a component subscribes to only the slice of state it needs:
// Zustand store — simple to write, selective subscriptions built in
import { create } from 'zustand';
const useCartStore = create((set) => ({
items: [],
total: 0,
addItem: (product) => set((state) => ({
items: [...state.items, product],
total: state.total + product.price,
})),
}));
// Component subscribes only to 'total' — doesn't re-render when items change
function CartBadge() {
const total = useCartStore(state => state.total); // selector
return <span>{total}</span>;
}
// Different component subscribes only to 'items'
function CartList() {
const items = useCartStore(state => state.items);
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
Zustand vs Redux: Zustand has minimal boilerplate (no actions/reducers/selectors setup), integrates seamlessly with TypeScript, and is easier to learn. Redux Toolkit is more structured, has excellent DevTools with time-travel debugging, and suits large teams where the extra convention helps. For most new projects, Zustand is the right default.
A major insight: most of what developers store in global state is actually server data — data fetched from an API that lives on the server. Managing this in Redux or useState means manually handling loading, error, caching, invalidation, and re-fetching. React Query (TanStack Query) handles all of this:
// Without React Query — manual boilerplate
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers).catch(setError).finally(() => setLoading(false));
}, []);
// With React Query — caching, deduplication, background refetch, built in
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
});
The query key is a cache key. If multiple components call useQuery({ queryKey: ['users'] }), only one request fires. The result is cached and shared automatically.
Some state belongs in the URL — filters, search queries, pagination, selected tabs. URL state persists across page refreshes, is shareable via link, and is accessible via the back button:
// Storing filter state in URL query params (React Router)
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') ?? 'all';
function handleFilter(value) {
setSearchParams({ category: value }); // survives page refresh, shareable link
}
Many developers reach for Redux or Context immediately when state needs to be 'shared' — before adding any library, try lifting state to the nearest common ancestor. It's the simplest solution and handles most sharing requirements without any added complexity.
Many developers put all application state in Redux or Zustand — this includes server data (fetched from APIs), which is better managed by React Query. Server state has different characteristics: it needs caching, deduplication, background refresh, and invalidation — none of which Redux provides out of the box.
Many developers treat Context and Redux/Zustand as equivalent alternatives — they solve different problems. Context has no selector system; all consumers re-render on any value change. Zustand/Redux have selective subscriptions; a component watching cart.total doesn't re-render when user.name changes. Context is injection, Redux/Zustand is state management.
Many developers think useState is always simpler than useReducer for everything — for state with multiple related transitions (a shopping cart with add/remove/clear operations, a form wizard with next/back/submit) useReducer is cleaner. It centralises the transition logic and makes it testable in isolation.
Many developers store loading and error states manually alongside server data in useState — React Query eliminates this entirely. isLoading, isError, data, and isFetching are all returned automatically, along with caching, background refetch, and cache invalidation. Most 'global state' in Redux is actually server state that belongs in React Query.
Many developers think Zustand is a toy and Redux is for 'serious' apps — Redux Toolkit and Zustand are both production-ready and widely used. The choice is about tradeoffs: Redux adds structure (useful for large teams), Zustand is minimal (useful for moving fast). Choosing based on team size and convention needs, not on perceived seriousness.
E-commerce cart: cart state lives in Zustand — multiple components (CartIcon, CartPage, CheckoutSummary) subscribe to different slices. Adding to cart only re-renders CartIcon (badge count) and not the entire page. Server cart state synced via React Query mutations.
User authentication: the current user object lives in a UserContext — it's read by dozens of components (nav, profile, permissions), rarely changes, and infrequent updates don't cause performance issues.
Data tables with filtering: filter state (search query, sort column, page number) lives in URL params — the filtered view is shareable via link, persists on page refresh, and works with the browser back button.
Dashboard with multiple data sources: React Query manages all server data — useQuery for user data, useQuery for analytics data, useQuery for notifications. Each query has its own cache key, loading state, and background refresh interval.
Form state in a multi-step wizard: useReducer manages the wizard state — each step dispatches actions that update step data and advance the wizard. The centralised reducer makes it easy to validate, navigate, and submit.
Real-time collaborative app: Zustand holds the local document state; WebSocket messages dispatch updates that merge into the Zustand store; React Query handles saving to the server with optimistic updates and rollback on failure.
What are the different state management options in React and when do you use each?
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.