React State Management: From Context API to Zustand and Redux
In part one, we covered React basics till React Router. Now let's dive into one of React's most crucial aspects: state management. As your React app grows, managing state across components becomes challenging. This guide will walk you through three popular solutions, from simple to complex.
Understanding the Problem: Prop Drilling
Before jumping into solutions, let's understand what we're solving. Consider this common scenario:
// The dreaded prop drilling
function App() {
const [user, setUser] = useState({ name: "John", role: "admin" });
const [theme, setTheme] = useState("light");
return (
<div>
<Header user={user} theme={theme} />
<Sidebar user={user} theme={theme} />
<MainContent user={user} theme={theme} setTheme={setTheme} />
</div>
);
}
function Header({ user, theme }) {
return (
<header className={theme}>
<Navigation user={user} theme={theme} />
</header>
);
}
function Navigation({ user, theme }) {
return (
<nav className={theme}>
<UserProfile user={user} />
<ThemeToggle theme={theme} />
</nav>
);
}
// Props passed through multiple levels just to reach deeply nested components!
This becomes messy quickly. Every intermediate component needs to accept and pass down props it doesn't even use. Let's fix this.
Solution 1: Context API - React's Built-in State Management
Context API lets you share data across components without prop drilling. Let's build it step by step.
Step 1: Create the Context
First, create a context to hold our shared state:
// contexts/UserContext.js
import { createContext, useContext, useState } from "react";
// 1. Create the context
const UserContext = createContext();
Step 2: Create the Provider
The provider component will hold our state and provide it to child components:
// contexts/UserContext.js (continued)
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const login = async (email, password) => {
setLoading(true);
try {
// Simulate API call
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error("Login failed:", error);
} finally {
setLoading(false);
}
};
const logout = () => {
setUser(null);
};
// This object will be passed to all consuming components
const value = {
user,
login,
logout,
loading,
};
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
Step 3: Create a Custom Hook
Make it easy to consume the context:
// contexts/UserContext.js (continued)
export function useUser() {
const context = useContext(UserContext);
// Always check if context exists
if (!context) {
throw new Error("useUser must be used within a UserProvider");
}
return context;
}
Step 4: Wrap Your App
Provide the context to your entire app:
// App.js
import { UserProvider } from "./contexts/UserContext";
function App() {
return (
<UserProvider>
<div className="app">
<Header />
<MainContent />
<Footer />
</div>
</UserProvider>
);
}
Step 5: Use the Context
Now any component can access user data without prop drilling:
// components/Header.js
import { useUser } from "../contexts/UserContext";
function Header() {
const { user, logout } = useUser();
return (
<header>
<h1>My App</h1>
{user ? (
<div>
<span>Welcome, {user.name}!</span>
<button onClick={logout}>Logout</button>
</div>
) : (
<LoginButton />
)}
</header>
);
}
// components/LoginButton.js
function LoginButton() {
const { login, loading } = useUser();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
</form>
);
}
Multiple Contexts Example
For larger apps, separate concerns into different contexts:
// contexts/ThemeContext.js
import { createContext, useContext, useState, useEffect } from "react";
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
// Load theme from localStorage
useEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
// Save theme to localStorage
useEffect(() => {
localStorage.setItem("theme", theme);
document.body.className = theme;
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
// App.js - Combining providers
function App() {
return (
<UserProvider>
<ThemeProvider>
<div className="app">
<Header />
<MainContent />
</div>
</ThemeProvider>
</UserProvider>
);
}
// Using both contexts
function Header() {
const { user, logout } = useUser();
const { theme, toggleTheme } = useTheme();
return (
<header className={`header ${theme}`}>
<h1>My App</h1>
<button onClick={toggleTheme}>{theme === "light" ? "🌙" : "☀️"}</button>
{user && (
<div>
<span>Hi, {user.name}!</span>
<button onClick={logout}>Logout</button>
</div>
)}
</header>
);
}
Solution 2: Zustand - Simple and Powerful
Zustand is a lightweight state management library that's easier than Redux but more powerful than Context API.
Step 1: Create a Store
// stores/userStore.js
import { create } from "zustand";
const useUserStore = create((set, get) => ({
// State
user: null,
loading: false,
error: null,
// Actions
login: async (email, password) => {
set({ loading: true, error: null });
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (response.ok) {
const user = await response.json();
set({ user, loading: false });
} else {
set({ error: "Invalid credentials", loading: false });
}
} catch (error) {
set({ error: error.message, loading: false });
}
},
logout: () => set({ user: null }),
clearError: () => set({ error: null }),
// Computed values
isLoggedIn: () => !!get().user,
}));
export default useUserStore;
Step 2: Use the Store
No providers needed! Just import and use:
// components/Header.js
import useUserStore from "../stores/userStore";
function Header() {
const { user, logout, isLoggedIn } = useUserStore();
return (
<header>
<h1>My App</h1>
{isLoggedIn() ? (
<div>
<span>Welcome, {user.name}!</span>
<button onClick={logout}>Logout</button>
</div>
) : (
<LoginForm />
)}
</header>
);
}
function LoginForm() {
const { login, loading, error, clearError } = useUserStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
login(email, password);
};
return (
<form onSubmit={handleSubmit}>
{error && (
<div className="error">
{error}
<button onClick={clearError}>✕</button>
</div>
)}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
</form>
);
}
Zustand with Persistence
Save state to localStorage automatically:
// stores/settingsStore.js
import { create } from "zustand";
import { persist } from "zustand/middleware";
const useSettingsStore = create(
persist(
(set) => ({
theme: "light",
language: "en",
notifications: true,
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
toggleNotifications: () =>
set((state) => ({
notifications: !state.notifications,
})),
}),
{
name: "app-settings", // localStorage key
}
)
);
export default useSettingsStore;
Solution 3: Redux - For Complex Applications
Redux is overkill for simple apps but essential for complex ones with multiple developers.
Step 1: Create Slices (Redux Toolkit)
// store/userSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
// Async action
export const loginUser = createAsyncThunk(
"user/login",
async ({ email, password }, { rejectWithValue }) => {
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error("Login failed");
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const userSlice = createSlice({
name: "user",
initialState: {
user: null,
loading: false,
error: null,
},
reducers: {
logout: (state) => {
state.user = null;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(loginUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(loginUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
export const { logout, clearError } = userSlice.actions;
export default userSlice.reducer;
Step 2: Configure Store
// store/index.js
import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./userSlice";
export const store = configureStore({
reducer: {
user: userReducer,
},
});
Step 3: Provide Store to App
// App.js
import { Provider } from "react-redux";
import { store } from "./store";
function App() {
return (
<Provider store={store}>
<div className="app">
<Header />
<MainContent />
</div>
</Provider>
);
}
Step 4: Use Redux in Components
// components/Header.js
import { useSelector, useDispatch } from "react-redux";
import { loginUser, logout, clearError } from "../store/userSlice";
function Header() {
const { user, loading, error } = useSelector((state) => state.user);
const dispatch = useDispatch();
const handleLogin = (email, password) => {
dispatch(loginUser({ email, password }));
};
const handleLogout = () => {
dispatch(logout());
};
return (
<header>
<h1>My App</h1>
{user ? (
<div>
<span>Welcome, {user.name}!</span>
<button onClick={handleLogout}>Logout</button>
</div>
) : (
<LoginForm onLogin={handleLogin} loading={loading} error={error} />
)}
</header>
);
}
When to Use What?
Use Context API when:
- Small to medium apps
- Sharing simple data (user info, theme, language)
- You want to stick with React's built-in tools
- State doesn't change frequently
Use Zustand when:
- You want something simpler than Redux
- Need computed values and actions
- Want automatic persistence
- Building modern React apps
Use Redux when:
- Large, complex applications
- Multiple developers working together
- Need predictable state updates
- Complex state logic with many actions
- Time-travel debugging is important
My personal favorite is Zustand because it’s less complex to implement than useContext
and powerful enough to handle both scalable and simple web applications.
Performance Optimization Tricks In React
1. Prevent Unnecessary Re-renders
// ❌ Bad: Creates new object every render
function BadProvider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
// ✅ Good: Memoize the value
function GoodProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(
() => ({
user,
setUser,
}),
[user]
);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
2. Split Contexts for Better Performance
// ✅ Split contexts to prevent unnecessary re-renders
const UserContext = createContext();
const UserActionsContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const actions = useMemo(
() => ({
login: (userData) => setUser(userData),
logout: () => setUser(null),
}),
[]
);
return (
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserContext.Provider>
);
}
3. Use React.memo for Expensive Components
// ✅ Prevent re-renders when props haven't changed
const ExpensiveComponent = memo(function ExpensiveComponent({ data }) {
// Expensive calculations or rendering
return <div>{/* Complex JSX */}</div>;
});
4. Optimize Selectors in Redux/Zustand
// ❌ Bad: Will re-run every time store updates
const todos = useSelector((state) => state.todos.filter((t) => !t.completed));
// ✅ Good: Memoized selector
const activeTodos = useSelector(
(state) => state.todos.filter((t) => !t.completed),
shallowEqual // Only re-run if the result array changes
);
// ✅ Even better: Create reusable selectors
const selectActiveTodos = createSelector(
(state) => state.todos,
(todos) => todos.filter((t) => !t.completed)
);
5. Batch State Updates
// ❌ Bad: Multiple state updates cause multiple re-renders
const handleUserUpdate = (newData) => {
setUser(newData);
setLoading(false);
setError(null);
};
// ✅ Good: Batch updates with useReducer
const [state, dispatch] = useReducer(userReducer, initialState);
const handleUserUpdate = (newData) => {
dispatch({
type: "USER_UPDATE_SUCCESS",
payload: newData,
});
};
6. Lazy Load Heavy Components
// ✅ Lazy load components that aren't immediately needed
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Quick Tips Summary
- Start simple: Use local state (useState) first, then Context API, then external libraries
- Don't over-optimize: Only optimize when you have actual performance problems
- Use the right tool: Context for simple sharing, Zustand for modern apps, Redux for complex apps
- Memoize wisely: Use useMemo and useCallback only when needed, not everywhere
- Split your state: Keep different concerns in separate stores/contexts
- Test your state logic: Pure functions are easier to test than component state
State management doesn't have to be complicated. Choose the right tool for your needs, follow these patterns, and your React apps will be both performant and maintainable.
Ready to implement better state management in your React apps? Start with the Context API for simple cases, and gradually move to more sophisticated solutions as your needs grow.
Jumped Straight to Part 2? Check out Part 1: React Essentials — From Components to Routing to grasp the foundational React concepts first.
Want to dive deeper into modern web development? Check out my other
Related Posts: