React State Management: useState, Context, Zustand, Redux and When to Use Each
State management is the most debated topic in React. The ecosystem has dozens of solutions, and new developers often reach for complex global state libraries before they need them. Understanding the full spectrum β from local state to Redux β and knowing when to use each is a skill that sets experienced React developers apart.
The State Management Spectrum
Think of state management as a spectrum from simple to complex:
codeuseState β lifted state β Context β Zustand/Jotai β Redux Toolkit β β β β β simple shared global global complex local between read-heavy with actions enterprise state siblings
Start at the left. Move right only when you have a concrete reason.
1. Local State with useState
The right choice for most state. If only one component needs the data, keep it there.
jsxfunction SearchBar() { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); async function handleSearch(e) { e.preventDefault(); setLoading(true); const data = await searchApi(query); setResults(data); setLoading(false); } return ( <form onSubmit={handleSearch}> <input value={query} onChange={e => setQuery(e.target.value)} /> {loading && <Spinner />} {results.map(r => <ResultItem key={r.id} result={r} />)} </form> ); }
When to use local state
- Form inputs
- UI state (open/closed, selected tab, hover)
- Data used only by one component
- Loading and error states for a single fetch
2. Lifting State
When two sibling components need to share state, lift it to their closest common parent.
jsxfunction App() { -- Lifted -- both children need this const [selectedUserId, setSelectedUserId] = useState(null); return ( <div> <UserList onSelect={setSelectedUserId} selectedId={selectedUserId} /> <UserDetail userId={selectedUserId} /> </div> ); }
Lifting state is often the right answer before reaching for a state library.
3. Context API
Context solves prop drilling β passing props through many layers just to reach a deeply nested component.
jsx// ThemeContext.jsx const ThemeContext = createContext(); export function ThemeProvider({ children }) { const [theme, setTheme] = useState("light"); const toggle = () => setTheme(t => t === "light" ? "dark" : "light"); return ( <ThemeContext.Provider value={{ theme, toggle }}> {children} </ThemeContext.Provider> ); } export const useTheme = () => { const ctx = useContext(ThemeContext); if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); return ctx; }; // Deep in the tree function ThemeButton() { const { theme, toggle } = useTheme(); // no prop drilling return <button onClick={toggle}>Current: {theme}</button>; }
Context performance caveat
Every consumer re-renders when the context value changes. Split contexts by update frequency to avoid unnecessary re-renders:
jsx-- Bad: one context object changes frequently const AppContext = createContext({ user, theme, cart }); -- Good: separate contexts const UserContext = createContext(user); const ThemeContext = createContext(theme); const CartContext = createContext(cart);
When to use Context
- Infrequently updated global data: current user, theme, locale, feature flags
- Avoiding prop drilling 3+ levels deep
- Small to medium apps with modest state complexity
4. Zustand
Zustand is a minimal, fast global state library. No boilerplate, no providers, works outside components.
javascript// store.js import { create } from "zustand"; const useCartStore = create((set, get) => ({ items: [], addItem: (product) => set((state) => { const existing = state.items.find(i => i.id === product.id); if (existing) { return { items: state.items.map(i => i.id === product.id ? { ...i, qty: i.qty + 1 } : i ), }; } return { items: [...state.items, { ...product, qty: 1 }] }; }), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id), })), clearCart: () => set({ items: [] }), getTotal: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0), })); export default useCartStore;
jsx// Any component, anywhere in the tree function CartIcon() { const items = useCartStore(state => state.items); return <span>Cart ({items.length})</span>; } function AddToCartButton({ product }) { const addItem = useCartStore(state => state.addItem); return <button onClick={() => addItem(product)}>Add to cart</button>; }
Why developers love Zustand
- No Provider wrapper needed
- Minimal boilerplate β store is just a function
- Selectors prevent unnecessary re-renders:
useStore(state => state.items)only re-renders whenitemschanges - Can be used outside React (in utilities, event handlers, async code)
When to use Zustand
- Global state shared across many components
- Moderate complexity β enough to justify a library, not complex enough for Redux
- When you want global state without Context re-render issues
5. Redux Toolkit
Redux Toolkit (RTK) is the modern, official Redux approach. It eliminates the verbosity of classic Redux while keeping its predictability.
javascript// store/cartSlice.js import { createSlice } from "@reduxjs/toolkit"; const cartSlice = createSlice({ name: "cart", initialState: { items: [], total: 0 }, reducers: { addItem: (state, action) => { const existing = state.items.find(i => i.id === action.payload.id); if (existing) { existing.qty += 1; } else { state.items.push({ ...action.payload, qty: 1 }); } state.total = state.items.reduce((s, i) => s + i.price * i.qty, 0); }, removeItem: (state, action) => { state.items = state.items.filter(i => i.id !== action.payload); state.total = state.items.reduce((s, i) => s + i.price * i.qty, 0); }, }, }); export const { addItem, removeItem } = cartSlice.actions; export default cartSlice.reducer;
javascript// store/index.js import { configureStore } from "@reduxjs/toolkit"; import cartReducer from "./cartSlice"; export const store = configureStore({ reducer: { cart: cartReducer }, });
jsx// Component import { useSelector, useDispatch } from "react-redux"; import { addItem } from "./store/cartSlice"; function AddToCartButton({ product }) { const dispatch = useDispatch(); const itemCount = useSelector(state => state.cart.items.length); return ( <button onClick={() => dispatch(addItem(product))}> Add to Cart ({itemCount}) </button> ); }
RTK Query: data fetching built in
RTK Query handles server state β fetching, caching, invalidation β without extra libraries:
javascriptimport { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; const api = createApi({ reducerPath: "api", baseQuery: fetchBaseQuery({ baseUrl: "/api" }), endpoints: (builder) => ({ getUsers: builder.query({ query: () => "/users" }), createUser: builder.mutation({ query: (body) => ({ url: "/users", method: "POST", body }), invalidatesTags: ["Users"], }), }), }); export const { useGetUsersQuery, useCreateUserMutation } = api; -- In component const { data: users, isLoading } = useGetUsersQuery();
When to use Redux Toolkit
- Large applications with complex, interconnected state
- Teams that benefit from strict state discipline and DevTools time-travel debugging
- When you need RTK Query's powerful server state management
- Codebases with many developers where predictable state updates prevent bugs
Choosing the Right Tool
| Scenario | Recommendation |
|---|---|
| Form input, UI toggle | useState |
| Shared between 2-3 siblings | Lift state |
| Global theme, auth, locale | Context API |
| Global UI state, cart, filters | Zustand |
| Large app, complex state, DevTools | Redux Toolkit |
| Server data (loading, caching) | RTK Query or React Query |
Practice React on Froquiz
State management is a key topic in mid-level and senior React interviews. Test your React knowledge on Froquiz across all difficulty levels.
Summary
- Start with
useStateβ it handles most UI state perfectly - Lift state when siblings need to share it, before reaching for global solutions
- Context solves prop drilling for infrequently-updated global data
- Zustand offers global state with minimal boilerplate and no Provider
- Redux Toolkit is the right choice for large, complex apps β use RTK Query for server state
- Matching the tool to the complexity prevents over-engineering