React State Management: From Context API to Zustand and Redux

December 20, 2024 (6mo ago)

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:

Use Zustand when:

Use Redux when:

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

  1. Start simple: Use local state (useState) first, then Context API, then external libraries
  2. Don't over-optimize: Only optimize when you have actual performance problems
  3. Use the right tool: Context for simple sharing, Zustand for modern apps, Redux for complex apps
  4. Memoize wisely: Use useMemo and useCallback only when needed, not everywhere
  5. Split your state: Keep different concerns in separate stores/contexts
  6. 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: