Back to Blog
React State Management: From Context API to Zustand and Redux
ReactJavaScriptMERN Stack

React State Management: From Context API to Zustand and Redux

Complete guide to React state management solutions: Context API for prop drilling, Zustand for simplicity, and Redux for complex apps with practical examples and performance tips

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

  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.

Related Posts

React Essentials: From Components to Routing

React Essentials: From Components to Routing

Master React from fundamentals to advanced concepts: Virtual DOM, Fiber architecture, Hooks, Router, Context API, and state management with Redux/Zustand

ReactJavaScriptMERN Stack
Read More
From Backend to Full Stack: Building React Frontend with Authentication (Part 2)

From Backend to Full Stack: Building React Frontend with Authentication (Part 2)

Connect your Express backend to React frontend. Learn Axios setup, CORS handling, Zustand state management, and build a complete authentication system.

MERN StackBackend Development
Read More
Building Your First MERN Stack Backend: From Zero to Authentication (Part 1)

Building Your First MERN Stack Backend: From Zero to Authentication (Part 1)

Learn to build a complete Node.js backend with Express, MongoDB, and authentication. Master servers, APIs, routing, and middleware with practical examples.

MERN StackBackend Development
Read More


Β© 2026. All rights reserved.