React State Management: Context API, Redux, Zustand, and Jotai
State management is one of the first architectural decisions you make when building a React application. The wrong choice can lead to prop drilling, unnecessary re-renders, and tangled code. React’s ecosystem offers solutions ranging from the built-in Context API to external libraries like Redux Toolkit, Zustand, and Jotai. Understanding where each fits is critical for scalable applications.
Local State vs Global State
Before reaching for a state management library, ask: does this state need to be global? React’s own useState and useReducer hooks are sufficient for most component-local state. Lifting state up to a common ancestor handles sharing between a few components. The React documentation recommends starting with local state and lifting it only when necessary.
Global state — shared across many components or routes — benefits from a dedicated solution. Signs you need one: prop drilling through five or more layers, state that must persist across route changes, or complex update logic shared by many components.
React Context API
React Context provides a way to pass data through the component tree without manually passing props at every level. It is built into React and requires no external dependencies:
const AuthContext = React.createContext(null);
function App() {
const [user, setUser] = useState(null);
return (
<AuthContext.Provider value={{ user, setUser }}>
<Main />
</AuthContext.Provider>
);
---When to Use Context
Context is ideal for low-frequency, relatively static data: themes, locale preferences, authentication status. It works well when the value changes infrequently. The React documentation explicitly warns that Context is not optimized for high-frequency updates — every consumer re-renders when the context value changes, regardless of whether the consumer uses the changed portion.
Performance Considerations
To mitigate unnecessary re-renders, split contexts by concern:
const ThemeContext = React.createContext('light');
const AuthContext = React.createContext(null);This way, a theme change does not cause authentication consumers to re-render. Additionally, memoizing context values with useMemo prevents re-renders when the parent re-renders without changing the context value.
useReducer for Complex State
useReducer is an alternative to useState for state logic that involves multiple sub-values or depends on previous state:
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
default: return state;
}
---It follows the same pattern as Redux — pure reducer functions and dispatched actions — without external dependencies. The React documentation recommends useReducer when the next state depends on the previous state in complex ways, or when the state shape is deeply nested.
Redux Toolkit
Redux has been the most popular state management library for React since 2015. Redux Toolkit (RTK), the official opinionated Redux wrapper introduced in 2019, significantly reduces boilerplate and eliminates common Redux mistakes.
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
incremented: (state) => { state.value += 1; },
decremented: (state) => { state.value -= 1; },
},
---);Redux Toolkit includes createSlice (which uses Immer internally for immutable updates), configureStore (which sets up DevTools and middleware), and RTK Query for data fetching. The Redux documentation, maintained by Mark Erikson, is widely regarded as one of the best learning resources in the React ecosystem.
When to Choose Redux
Redux excels in large applications with complex state interactions, multiple data sources, and requirements for time-travel debugging and action logging. The Redux team advises against using Redux in small apps where React’s built-in tools suffice. Dan Abramov, Redux co-creator, has said: “Don’t use Redux until you have problems with vanilla React.”
Zustand
Zustand is a tiny (1 KB), fast, and scalable state management solution. It uses a simplified Flux pattern with hooks and requires no providers or actions:
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
---));Components access state via hooks: const count = useStore((state) => state.count). Zustand automatically applies selectors to minimize re-renders. It supports middleware (persist, immer, devtools) and works outside React for situations where you need shared state accessible from non-component code.
Zustand has gained significant traction in the React community for its simplicity and performance. It is particularly well-suited for medium-sized applications or teams that find Redux’s boilerplate excessive.
Jotai
Jotai takes an atomic approach to state management. Instead of a single store, you define individual atoms of state that can be composed:
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);Jotai’s primitive atoms eliminate unnecessary re-renders by design — only components that subscribe to a specific atom re-render when that atom changes. This is similar to Recoil (which was developed at Meta but is no longer actively maintained) but with a simpler API and better performance characteristics.
Server State vs Client State
A critical distinction in modern state management is server state versus client state. Server state (data from an API) is owned by the server and cached on the client. Client state (UI state, form inputs, modal visibility) is owned entirely by the browser.
Libraries like React Query (TanStack Query) specialize in server state management, providing caching, background refetching, optimistic updates, and stale-while-revalidate patterns. Combining React Query with Zustand for client state creates a clean separation:
// Server state
const { data: posts } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
// Client state
const { isSidebarOpen, toggleSidebar } = useSidebarStore();Mixing server and client state in the same store is an anti-pattern. Server state belongs in a caching layer with automatic synchronization, while client state belongs in a store with explicit updates.
Server Components and State
React Server Components fundamentally change state management because Server Components cannot use hooks or client-side state. State management responsibilities shift:
- Server Components handle data fetching and pass results as props to Client Components
- Client Components manage UI state, user interactions, and client-side data caching
- Server Actions handle mutations, with automatic cache revalidation
// Server Component — fetches data
export default async function Page() {
const posts = await db.post.findMany();
return <PostList posts={posts} />;
---
// Client Component — manages UI state
function PostList({ posts }) {
const [filter, setFilter] = useState('all');
const filtered = filter === 'all' ? posts : posts.filter(p => p.category === filter);
return <div>{filtered.map(p => <Post key={p.id} post={p} />)}</div>;
---This architecture eliminates the need for client-side stores for server data. The server is the single source of truth, and the client only manages transient UI state. Libraries like React Query complement RSC by handling client-side caching and optimistic updates for mutations.
URL as State Management
The URL itself is an underutilized state management tool. Query parameters, hash fragments, and route params can represent application state that should be shareable and bookmarkable:
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') || 'all';
const page = parseInt(searchParams.get('page') || '1');
return (
<div>
<select value={category} onChange={e => setSearchParams({ category: e.target.value, page: '1' })}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
</select>
</div>
);
---URL-based state provides unique benefits: deep linking, browser back/forward navigation, no memory leaks, and persistence across sessions without explicit storage code. The React Router documentation on useSearchParams demonstrates this pattern for filter, search, and pagination states.
Persisting State
For state that must survive page reloads, persistence is necessary. Zustand provides a persist middleware:
const useStore = create(persist(
(set) => ({ count: 0, increment: () => set(s => ({ count: s.count + 1 })) }),
{ name: 'counter-storage' }
));Redux Toolkit supports persistence through redux-persist, which serializes the store to localStorage or IndexedDB. Both handle rehydration on app startup. The React documentation recommends limiting persisted state to user preferences and draft data rather than caching server responses.
When to Choose Which
| Solution | Best For | Bundle Size |
|---|---|---|
| useState/useReducer | Component-local state | 0 KB |
| Context API | Low-frequency global state (theme, auth) | 0 KB |
| Redux Toolkit | Large apps, complex state logic | ~12 KB |
| Zustand | Medium apps, simplicity, speed | ~1 KB |
| Jotai | Granular subscriptions, atomic state | ~3 KB |
FAQ
Do I need Redux for every React app?
No. Many applications work fine with React’s built-in state management. Start with useState and Context. Introduce Redux only when you encounter problems that local state cannot solve.
What is the difference between Context and Redux?
Context is a dependency injection mechanism, not a state management solution. Redux provides a predictable state container with DevTools, middleware, and time-travel debugging. Context re-renders all consumers on any change; Redux (via useSelector) only re-renders components whose selected state changed.
Can I use Zustand with Redux DevTools?
Yes. Zustand provides a devtools middleware that connects to Redux DevTools, giving you time-travel debugging without Redux’s boilerplate.
Should I use Immer for immutable state?
Immer is included in Redux Toolkit by default and available as middleware for Zustand. It simplifies immutable update syntax but adds some overhead. For deeply nested state, Immer is worth the trade-off.
Conclusion
State management in React is not one-size-fits-all. React’s built-in useState, useReducer, and Context API handle most needs. For larger applications, Redux Toolkit provides a robust, battle-tested solution. Zustand and Jotai offer modern alternatives with smaller footprints and simpler APIs. The Redux documentation, Zustand README, and Jotai GitHub repo are excellent resources for deepening your understanding. For form-specific state management, see React Forms Guide, and for routing state, see React Router Guide.
For a comprehensive overview, read our article on Next Js Guide.
For a comprehensive overview, read our article on React Authentication.