State Management Solutions
Manage complex application state beyond useContext
State Management Solutions in React
Master React state management with free flashcards and spaced repetition practice. This comprehensive lesson covers the Context API, Redux, Zustand, and modern state management patternsβessential tools for building scalable React applications. You'll learn when to use each solution, implement practical examples, and avoid common pitfalls that plague React developers.
Welcome to Advanced State Management! π―
State management is one of the most critical aspects of building React applications. As your app grows beyond a few components, managing state effectively becomes challenging. Should you lift state up? Use Context? Reach for Redux? This lesson will guide you through the ecosystem of state management solutions, helping you choose the right tool for each scenario.
What You'll Learn:
- π Understanding different types of state (local, global, server)
- π Context API for prop drilling elimination
- π¦ Redux architecture and modern Redux Toolkit
- β‘ Zustand for lightweight global state
- π¨ Choosing the right solution for your needs
π‘ Pro Tip: The best state management solution is often the simplest one that solves your problem. Don't reach for Redux if Context API will do!
Core Concepts: Understanding State in React π»
Types of State in React Applications
Before diving into solutions, let's categorize state:
| State Type | Description | Examples | Best Tool |
|---|---|---|---|
| Local State | Data needed by a single component | Form inputs, toggles, counters | useState, useReducer |
| Shared State | Data shared between components | User info, theme, language | Context API, Zustand |
| Remote/Server State | Data fetched from APIs | User profiles, product lists | React Query, SWR |
| URL State | Data stored in the URL | Filters, pagination, search | React Router, Next.js router |
π§ Memory Device: Think "LSRU" - Local, Shared, Remote, URL
The Context API: React's Built-in Solution π
Context API solves prop drilling - the problem of passing props through many layers of components. It's built into React and perfect for sharing data like themes, auth status, or user preferences.
How Context Works:
βββββββββββββββββββββββββββββββββββββββ
β Context Provider β
β (Holds the state/value) β
βββββββββββ¬ββββββββββββββββββββββββββββ
β
βββββββ΄ββββββ
β β
βββββΌββββ βββββΌββββ
βChild Aβ βChild Bβ
βββββ¬ββββ βββββ¬ββββ
β β
βββββΌββββ βββββΌββββ
βChild Cβ βChild Dβ β All can access
βββββββββ βββββββββ context value
Key Characteristics:
- β Built into React (no dependencies)
- β Simple API (createContext, Provider, useContext)
- β οΈ Can cause unnecessary re-renders if not optimized
- β οΈ Not ideal for frequently changing state
Redux: The Enterprise Standard π¦
Redux implements a unidirectional data flow pattern with a single source of truth. Modern Redux uses Redux Toolkit (RTK) which dramatically simplifies the setup.
Redux Architecture:
ββββββββββββ dispatch ββββββββββββ
βComponent ββββββββββββ β Action β
ββββββ²ββββββ βββββββ¬βββββ
β β
β βΌ
β ββββββββββββ
β β Reducer β
β βββββββ¬βββββ
β β
β subscribe βΌ
β ββββββββββββ
βββββββββββββββββββββ Store β
ββββββββββββ
Single source of truth
When to Use Redux:
- π’ Large applications with complex state logic
- π State needs to be accessible across many components
- π You need powerful debugging (Redux DevTools)
- π§ͺ Predictable state updates are critical
Zustand: The Lightweight Alternative β‘
Zustand (German for "state") is a minimal state management library that's become increasingly popular. It uses hooks and requires minimal boilerplate.
Zustand Philosophy:
- π― Simple and unopinionated
- πͺΆ Tiny bundle size (~1KB)
- β‘ No providers needed
- π¨ Works with React's Suspense
π€ Did You Know?
Redux was inspired by Flux architecture from Facebook and Elm's architecture. The creator, Dan Abramov, built Redux as a learning exercise and it became the de facto standard for React state management!Example 1: Context API for Theme Management π¨
Let's build a theme switcher using Context API. This is a perfect use case - theme doesn't change frequently and needs to be accessible throughout the app.
import React, { createContext, useContext, useState } from 'react';
// 1. Create Context
const ThemeContext = createContext();
// 2. Create Provider Component
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
const value = {
theme,
toggleTheme
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// 3. Create Custom Hook for Easy Access
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// 4. Use in Components
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#000' : '#fff'
}}>
<h1>My App</h1>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode
</button>
</header>
);
}
// 5. Wrap App with Provider
function App() {
return (
<ThemeProvider>
<Header />
{/* Other components */}
</ThemeProvider>
);
}
Key Points:
- ποΈ Separation of concerns: Provider logic is isolated
- π£ Custom hook:
useTheme()provides clean API and error handling - π Error boundary: Throws error if used outside provider
- π¦ Value object: Pass multiple values/functions together
π‘ Performance Tip: If your context value changes frequently, split into multiple contexts to prevent unnecessary re-renders!
Example 2: Redux Toolkit for Shopping Cart π
Let's implement a shopping cart using modern Redux Toolkit. This demonstrates Redux's strength with complex state logic.
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { useSelector, useDispatch, Provider } from 'react-redux';
// 1. Create Slice (combines actions + reducer)
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
total: 0
},
reducers: {
addItem: (state, action) => {
const existingItem = state.items.find(
item => item.id === action.payload.id
);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
// Redux Toolkit uses Immer - direct mutation is safe!
state.total += action.payload.price;
},
removeItem: (state, action) => {
const item = state.items.find(item => item.id === action.payload);
if (item) {
state.total -= item.price * item.quantity;
state.items = state.items.filter(item => item.id !== action.payload);
}
},
updateQuantity: (state, action) => {
const { id, quantity } = action.payload;
const item = state.items.find(item => item.id === id);
if (item) {
state.total -= item.price * item.quantity;
item.quantity = quantity;
state.total += item.price * quantity;
}
},
clearCart: (state) => {
state.items = [];
state.total = 0;
}
}
});
// 2. Export Actions
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
// 3. Create Store
const store = configureStore({
reducer: {
cart: cartSlice.reducer
}
});
// 4. Use in Components
function ProductCard({ product }) {
const dispatch = useDispatch();
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => dispatch(addItem(product))}>
Add to Cart
</button>
</div>
);
}
function Cart() {
const { items, total } = useSelector(state => state.cart);
const dispatch = useDispatch();
return (
<div className="cart">
<h2>Shopping Cart</h2>
{items.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => dispatch(updateQuantity({
id: item.id,
quantity: parseInt(e.target.value)
}))}
/>
<button onClick={() => dispatch(removeItem(item.id))}>
Remove
</button>
</div>
))}
<div className="cart-total">
<strong>Total: ${total.toFixed(2)}</strong>
</div>
<button onClick={() => dispatch(clearCart())}>
Clear Cart
</button>
</div>
);
}
// 5. Wrap App
function App() {
return (
<Provider store={store}>
{/* App components */}
</Provider>
);
}
Redux Toolkit Advantages:
- π― Less boilerplate: No action types, action creators simplified
- π§ Immer integration: Write "mutating" code safely
- π¦ Built-in DevTools: Time-travel debugging included
- β‘ createAsyncThunk: Easy async action handling
π§ Remember: Redux actions describe what happened, reducers decide how state changes.
Example 3: Zustand for Simple Global State β‘
Zustand shines when you need simple global state without Redux's ceremony. Here's a user authentication store:
import create from 'zustand';
import { persist } from 'zustand/middleware';
// 1. Create Store (no provider needed!)
const useAuthStore = create(
persist(
(set, get) => ({
// State
user: null,
token: null,
isAuthenticated: false,
// Actions
login: async (credentials) => {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
});
const data = await response.json();
set({
user: data.user,
token: data.token,
isAuthenticated: true
});
} catch (error) {
console.error('Login failed:', error);
throw error;
}
},
logout: () => {
set({
user: null,
token: null,
isAuthenticated: false
});
},
updateProfile: (updates) => {
set(state => ({
user: { ...state.user, ...updates }
}));
},
// Derived state (computed values)
getUserName: () => {
const { user } = get();
return user ? `${user.firstName} ${user.lastName}` : 'Guest';
}
}),
{
name: 'auth-storage', // localStorage key
getStorage: () => localStorage // or sessionStorage
}
)
);
// 2. Use in Components (no Provider!)
function LoginForm() {
const login = useAuthStore(state => state.login);
const [credentials, setCredentials] = useState({ email: '', password: '' });
const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(credentials);
} catch (error) {
alert('Login failed');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={credentials.email}
onChange={(e) => setCredentials({
...credentials,
email: e.target.value
})}
/>
<input
type="password"
value={credentials.password}
onChange={(e) => setCredentials({
...credentials,
password: e.target.value
})}
/>
<button type="submit">Login</button>
</form>
);
}
function UserProfile() {
// Select only what you need (prevents unnecessary re-renders)
const user = useAuthStore(state => state.user);
const getUserName = useAuthStore(state => state.getUserName);
const updateProfile = useAuthStore(state => state.updateProfile);
if (!user) return <div>Please log in</div>;
return (
<div>
<h2>Welcome, {getUserName()}!</h2>
<p>Email: {user.email}</p>
<button onClick={() => updateProfile({ theme: 'dark' })}>
Switch to Dark Mode
</button>
</div>
);
}
function Header() {
// Subscribe to only isAuthenticated
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
const logout = useAuthStore(state => state.logout);
return (
<header>
{isAuthenticated ? (
<button onClick={logout}>Logout</button>
) : (
<a href="/login">Login</a>
)}
</header>
);
}
Zustand Benefits:
- π― No boilerplate: Define state and actions in one place
- π« No provider: Use anywhere in your app immediately
- π¨ Selective subscriptions: Components only re-render when their slice changes
- πΎ Middleware support: Persist, devtools, immer built-in
- πͺΆ Tiny: Minimal impact on bundle size
π‘ Performance Tip: Use selectors wisely! useAuthStore(state => state.user.email) is better than useAuthStore(state => state).user.email because it only re-renders when email changes.
Example 4: Combining Solutions - The Hybrid Approach π
In real applications, you often combine multiple state management solutions. Here's a practical example:
import create from 'zustand';
import { useQuery, useMutation, QueryClient, QueryClientProvider } from 'react-query';
import { createContext, useContext, useState } from 'react';
// 1. LOCAL STATE: Component-specific (useState)
function SearchBar() {
const [query, setQuery] = useState(''); // Only SearchBar needs this
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
// 2. ZUSTAND: UI State (global, synchronous)
const useUIStore = create((set) => ({
sidebarOpen: false,
modalOpen: false,
theme: 'light',
toggleSidebar: () => set(state => ({
sidebarOpen: !state.sidebarOpen
})),
openModal: () => set({ modalOpen: true }),
closeModal: () => set({ modalOpen: false }),
setTheme: (theme) => set({ theme })
}));
// 3. REACT QUERY: Server State (async data)
function ProductList() {
// React Query handles loading, error, caching, refetching
const { data: products, isLoading, error } = useQuery(
'products',
async () => {
const response = await fetch('/api/products');
return response.json();
},
{
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
cacheTime: 10 * 60 * 1000 // Keep in cache for 10 minutes
}
);
const mutation = useMutation(
(newProduct) => {
return fetch('/api/products', {
method: 'POST',
body: JSON.stringify(newProduct)
});
},
{
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries('products');
}
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// 4. CONTEXT: Cross-cutting concerns (feature flags, i18n)
const FeatureFlagsContext = createContext();
function FeatureFlagsProvider({ children }) {
const [flags] = useState({
newCheckout: true,
betaFeatures: false,
aiRecommendations: true
});
return (
<FeatureFlagsContext.Provider value={flags}>
{children}
</FeatureFlagsContext.Provider>
);
}
function CheckoutButton() {
const flags = useContext(FeatureFlagsContext);
return flags.newCheckout ? (
<NewCheckout />
) : (
<OldCheckout />
);
}
// 5. PUTTING IT ALL TOGETHER
const queryClient = new QueryClient();
function App() {
const sidebarOpen = useUIStore(state => state.sidebarOpen);
return (
<QueryClientProvider client={queryClient}>
<FeatureFlagsProvider>
<div className={sidebarOpen ? 'with-sidebar' : ''}>
<Sidebar />
<main>
<ProductList />
</main>
</div>
</FeatureFlagsProvider>
</QueryClientProvider>
);
}
Decision Matrix:
| Use Case | Best Solution | Why |
|---|---|---|
| Form inputs | useState | Component-specific, temporary |
| Theme, language | Context API | Rarely changes, tree-wide |
| UI state (modals, sidebar) | Zustand | Simple, synchronous, global |
| API data | React Query/SWR | Caching, revalidation, loading states |
| Complex business logic | Redux | Predictable, debuggable, time-travel |
| URL params | React Router | Shareable, bookmarkable state |
π§ Mental Model: Think of state as layers:
- Bottom layer: Local state (useState)
- Middle layer: Shared UI state (Context/Zustand)
- Top layer: Server state (React Query)
- Side layer: URL state (Router)
Common Mistakes & How to Avoid Them β οΈ
Mistake 1: Context for Everything
β Wrong:
// Using Context for frequently changing data
function App() {
const [count, setCount] = useState(0);
// Every component re-renders on count change!
return (
<CountContext.Provider value={{ count, setCount }}>
<Header /> {/* Re-renders */}
<Sidebar /> {/* Re-renders */}
<Footer /> {/* Re-renders */}
<Counter /> {/* Only this needs count! */}
</CountContext.Provider>
);
}
β Right:
// Use local state or Zustand for frequent updates
const useCountStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 }))
}));
function Counter() {
// Only Counter re-renders
const count = useCountStore(state => state.count);
return <div>{count}</div>;
}
Why it matters: Context causes all consumers to re-render, even if they don't use the changed value.
Mistake 2: Not Memoizing Context Values
β Wrong:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// New object every render!
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
β Right:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(
() => ({ theme, setTheme }),
[theme]
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Mistake 3: Redux for Everything
β Wrong:
// Putting form state in Redux
const formSlice = createSlice({
name: 'form',
initialState: {
firstName: '',
lastName: '',
email: ''
},
reducers: {
updateFirstName: (state, action) => {
state.firstName = action.payload;
},
// More boilerplate for simple local state...
}
});
β Right:
// Keep form state local
function ContactForm() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: ''
});
// Only put in Redux when submitting
const dispatch = useDispatch();
const handleSubmit = () => {
dispatch(submitContact(formData));
};
}
Mistake 4: Not Splitting Redux State Properly
β Wrong:
// One giant reducer
const appReducer = createSlice({
name: 'app',
initialState: {
users: [],
products: [],
orders: [],
ui: {},
// Everything in one place!
}
});
β Right:
// Split by domain
const store = configureStore({
reducer: {
users: usersReducer,
products: productsReducer,
orders: ordersReducer,
ui: uiReducer
}
});
Mistake 5: Mixing Server and Client State
β Wrong:
// Storing API data in Zustand/Redux
const useStore = create((set) => ({
products: [],
loading: false,
fetchProducts: async () => {
set({ loading: true });
const data = await fetch('/api/products');
set({ products: data, loading: false });
// No caching, stale data, manual refetch logic!
}
}));
β Right:
// Use React Query for server state
function ProductList() {
const { data, isLoading } = useQuery(
'products',
() => fetch('/api/products').then(r => r.json()),
{
staleTime: 5000,
// Automatic caching, refetching, background updates!
}
);
}
Mistake 6: Over-selecting in Zustand
β Wrong:
// Component re-renders on ANY store change
function UserName() {
const store = useStore(); // Gets entire store
return <div>{store.user.name}</div>;
}
β Right:
// Component only re-renders when name changes
function UserName() {
const name = useStore(state => state.user.name);
return <div>{name}</div>;
}
Performance Rule: Always select the smallest slice you need!
Key Takeaways π―
π Quick Reference Card
| Solution | Best For | Bundle Size | Learning Curve |
|---|---|---|---|
| useState/useReducer | Local component state | 0KB (built-in) | β Easy |
| Context API | Infrequent global updates | 0KB (built-in) | ββ Easy |
| Zustand | Simple global state | ~1KB | ββ Easy |
| Redux Toolkit | Complex state logic | ~11KB | βββ Moderate |
| React Query | Server/async state | ~13KB | βββ Moderate |
| Jotai/Recoil | Atomic state | ~3KB | βββ Moderate |
Decision Framework:
Start Here
β
βββββββββββββββββββββββ
β Is it local state? β
ββββββββ¬βββββββββββββββ
β
YES β NO
β
β β
useState ββββββββββββββββββββββββ
β Is it server data? β
ββββββββ¬ββββββββββββββββ
β
YES β NO
β
β β
React Query βββββββββββββββββββββ
β Is it complex? β
ββββββββ¬βββββββββββββ
β
YES β NO
β
β β
Redux Context/Zustand
Golden Rules:
- π― Start simple - Use useState until you can't
- π Separate concerns - Server state β Client state
- β‘ Optimize subscriptions - Select only what you need
- π§ͺ Measure first - Profile before optimizing
- π¦ Consider bundle size - Every KB matters
- π¨ Match patterns to problems - Don't force solutions
Essential Patterns to Remember
The Compound Component Pattern with Context:
// Powerful for building flexible APIs
const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState(0);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
};
Tabs.List = ({ children }) => <div role="tablist">{children}</div>;
Tabs.Tab = ({ index, children }) => {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
onClick={() => setActiveTab(index)}
aria-selected={activeTab === index}
>
{children}
</button>
);
};
The Observer Pattern with Zustand:
// Subscribe to changes outside React
const unsubscribe = useStore.subscribe(
(state) => state.user,
(user) => {
console.log('User changed:', user);
// Sync with analytics, localStorage, etc.
}
);
π‘ Pro Tip: When in doubt, check React's official recommendations. The React team now suggests React Query/SWR for server state and Zustand/Jotai for client state over Redux in many cases.
π Further Study
Official Documentation:
- React Context API Official Docs - Complete guide to Context from React team
- Redux Toolkit Official Guide - Modern Redux best practices and quick start
- Zustand GitHub Documentation - Simple state management with practical examples
Advanced Topics to Explore Next:
- π State machines with XState
- π― Atomic state with Jotai and Recoil
- π‘ Real-time state with WebSockets
- π§ͺ Testing state management logic
- β‘ Server components and React Server Components (RSC)