React State Management with Context and useReducer

Updated on December 10, 2025 16 minutes read


React is still one of the most popular ways to build rich client‑side applications in 2026. Modern apps fetch data, handle complex interactions, and update the UI in real time – all inside the browser. That power comes with a cost: you need a clear strategy for managing the state.

As your app grows, a state that starts as a simple variable in one component can quickly become a tangle of props, duplicated values, and mysterious bugs. A solid state management approach makes your code easier to reason about, debug, and extend.

In this article, we will walk through a realistic authentication workflow. We will start with useState and props, then refactor to the Context API and useReducer. Along the way, you will see why these tools are still core to React in 2026 and how they fit into a scalable mental model.

Authentication Workflow: The Example App

The code used in this article is available in the fullstack-resourcify repository on GitHub.

Our goal is to let users sign up or log in with credentials, then navigate them to the home page when authentication succeeds. Logged‑out users should only see the login page, while logged‑in users should have access to the home page and its resources.

When the user logs out, the app should immediately restrict access to protected routes. In other words, the current auth state (logged in or logged out) should drive what the user can see and do.

Thinking in Components and State

From an implementation perspective, imagine a main component called App. This component decides whether to show the Home or Login page based on the current user state. When the user state changes, the app should re‑render and redirect accordingly.

If user is null, no one is authenticated, so the app must show the login page and protect the home page via conditional rendering. If user exists, the home page becomes accessible, and the login page should redirect away.

Once the behaviour is clear, we can think about the tools. We will set up a small React project, wire up some routes, and then experiment with different ways of sharing state across components.

Project Setup

The repository includes a minimal backend so that you can focus on the frontend and the auth flow. The backend code is intentionally simple so you can skim it and get back to the React logic.

In the frontend, we will create the following pages and components:

  • Home page
  • Login page
  • EditUser page
  • Navbar component
  • UserDropdown component

Because this is a multi‑page React application, we will use react-router-dom for navigation. Install it in the frontend project:

yarn add react-router-dom

If you prefer to follow along step by step, use the starter template. The repository contains a start-here branch that includes pages, components, and routing, plus DaisyUI for Tailwind CSS UI components. Fork the repo, then clone your fork and switch to the starter branch:

git clone git@github.com:<your-username>/fullstack-resourcify.git
cd fullstack-resourcify
git checkout start-here

Once you have the start-here branch locally:

  • Open the project in your favourite code editor.
  • Change directory to frontend/.
  • Install dependencies with yarn.
  • Start a development server with yarn dev.

You should see a basic UI with a navbar and some placeholder pages.

The name displayed in the top‑right of the navbar is a state variable defined in the main App component. That state is passed down to both Navbar and Home. The form on the EditUser page updates the name state when you submit it.

Next, we will look at two approaches for sharing that state: first with useState and props, then with the Context API.

First Approach: Managing State with useState

Understanding useState()

useState is one of the most commonly used React hooks. It lets you create and update state inside a functional component. You call useState with an initial va, and it returns a pair: the current state value and a setter function.

The basic pattern looks like this:

const [state, setState] = useState(initialValue);

In our example, the name shown in the navbar is defined as a state inside App. We then pass that state, and optionally a setter, down to child components. Conceptually, useState is a tiny, specialised state container scoped to a single component.

Here is how we create the name state variable:

./src/App.jsx

import { useState } from "react";

function App() {
  const testValue = "CLA";

  // Using the useState hook to create a state variable from an initial value
  const [name, setName] = useState(testValue);
  console.log(`Rendering: ${name}`);

  return (
    // ...
  );
}

export default App;

Whenever setName is called, React schedules a re‑render of the App component with the new value of name.

Props: Passing State Down the Tree

Props are another fundamental building block in React. If you think of a functional component as a JavaScript function, props are its parameters. Combining props with useState lets you share state from a parent down to child components.

You pass props as attributes on your custom components and access them through the component’s argument. Here we pass name and setName from App to Home and EditUser:

<Routes>
  <Route path="/" element={<Home name={name} />} />
  <Route
    path="/user"
    element={<EditUser name={name} setName={setName} />}
  />
  <Route path="/login" element={<Login />} />
</Routes>

Inside a component, props can be used like function arguments. The common pattern is to use destructuring to pull out the values you need:

./src/pages/Home.jsx

// ...

export default function Home({ name }) {
  console.log("Rendering: Home");

  return (
    <div className="flex flex-col bg-white m-auto p-auto">
      <h1 className="flex py-5 lg:px-20 md:px-10 mx-5 font-bold text-2xl text-gray-800">
        Welcome {name}
      </h1>
      {/* ... */}
    </div>
  );
}

Because Home receives name as a prop, React re‑renders Home whenever name changes in App. The UI stays in sync with the latest state.

Pro Tip: Watch the Re-renders

Open your browser’s dev tools and keep an eye on the console. Each time you change the name value, you will see log messages from every component that uses name as a prop.

Whenever you call a state setter like setName, React stores the next state, re‑runs the component function, and updates the UI with the new output. This is the core of how React’s declarative model works.

The Limits of useState: Prop Drilling and Complexity

Prop Drilling

“Prop drilling” describes what happens when you need to pass a piece of data through several layers of components that do not directly use it. Intermediate components become just a conduit for props, and any change to the state can trigger re‑renders all the way down the tree.

In our example:

  • name is defined in App with useState.
  • App passes name down to Navbar.
  • Navbar passes name again to UserDropdown.
  • UserDropdown is the component that actually needs to render name.

./src/App.jsx

// ...

function App() {
  const [name, setName] = useState("CLA");
  console.log("Rendering: App");

  return (
    <BrowserRouter>
      <div className="h-screen">
        <Navbar name={name} />
        <main className="px-4">
          {/* ... */}
        </main>
      </div>
    </BrowserRouter>
  );
}

export default App;

./src/components/Navbar.jsx

import Logo from "../assets/cla.svg";
import { Link } from "react-router-dom";
import UserDropdown from "./UserDropdown";

export default function Navbar({ name }) {
  console.log("Rendering: Navbar");

  return (
    <div className="navbar bg-base-100 drop-shadow-sm">
      <div className="flex-1">
        <Link
          to="/"
          className="btn btn-ghost normal-case text-md md:text-xl px-2 gap-1"
        >
          <img src={Logo} className="h-6" alt="Code Labs Academy logo" />
          Resources
        </Link>
      </div>

      <UserDropdown name={name} />
    </div>
  );
}

As your app grows, deep prop chains are hard to maintain and reason about. A single change can trigger many unnecessary re‑renders, and it becomes easy to lose track of where a particular value is coming from.

Growing State Complexity

If you rely only on useState and props for every piece of state, your codebase will quickly gain dozens of state variables spread across many components. Some will be duplicates, some will be tightly coupled, and all of them need to be kept in sync.

Refactoring then becomes risky because changing one component’s state can have knock‑on effects in unexpected places. This is where tools like the Context API and useReducer start to shine.

Second Approach: Context API for Shared State

The Context API was added to React to make it easier to share data that is considered “global” to a tree of components, such as themes, locale, or authentication. Instead of passing props down through every level, components can subscribe to a context and read the value directly.

For truly global data like the authenticated user, context is often a much better fit than prop drilling. For more local state shared among a few siblings, patterns like component composition can also work well.

Creating the Auth Context

Creating a context is straightforward. You call createContext() to define a new context object. It can take an optional default value, but you will usually provide a real value through its Provider.

./src/contexts/Auth.jsx

import { createContext, useReducer, useEffect } from "react";

export const Auth = createContext();

Next, we need a provider component. This component will hold our auth state and expose it through Auth.Provider so that any nested component can subscribe.

Providing the Auth Context with useReducer

We want to store a user object and update it in a controlled way. While we could keep using useState, the logic becomes easier to manage with useReducer as the state grows.

First, define a reducer that handles the different auth actions:

const authReducer = (state, action) => {
  switch (action.type) {
  Casese "LOGIN":
      Return { user: action.payload };

    Case "LOGOUT":
      localStorage.removeItem("user");
      return { user: null };

    Default:
      return state;
  }
};

Now we can use this reducer inside our provider and expose both the state and the dispatch function via context:

export const AuthProvider = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
  });

  // Keep user logged in if we have data in localStorage
  useEffect(() => {
    const storedUser = localStorage.getItem("user");
    if (storedUser) {
      const user = JSON.parse(storedUser);
      dispatch({ type: "LOGIN", payload: user });
    }
  }, []);

  return (
    <Auth.Provider value={{ ...state, dispatch }}>
      {children}
    </Auth.Provider>
  );
};

Any component wrapped by AuthProvider can now access the current user and the dispatch function without props.

Wrapping the App in AuthProvider

To make the context available throughout the app, we wrap the root App component with AuthProvider.

./src/main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { AuthProvider } from "./contexts/Auth";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")).render(
  <AuthProvider>
    <App />
  </AuthProvider>
);

Because AuthProvider renders {children} inside Auth.Provider, every component inside App can subscribe to the auth context.

Consuming Context with useContext

The last step is to read values from the context using the useContext hook. Instead of accepting props, components can now subscribe directly to Auth.

./src/components/Navbar.jsx

import { useContext } from "react";
import { Auth } from "../contexts/Auth";
import Logo from "../assets/cla.svg";
import { Link } from "react-router-dom";

export default function Navbar() {
  const { user } = useContext(Auth);
  console.log("Rendering: Navbar");

  return (
    <div className="navbar bg-base-100 drop-shadow-sm">
      <div className="flex-1">
        <Link
          to="/"
          className="btn btn-ghost normal-case text-md md:text-xl px-2 gap-1"
        >
          <img src={Logo} className="h-6" alt="Code Labs Academy logo" />
          Resources
        </Link>
      </div>

      {/* You can safely read user data from context here */}
      {/* ... */}
    </div>
  );
}

Notice how Navbar no longer accepts name or user as props. Instead, it calls useContext(Auth) and gets the values it needs directly from the context.

You can do the same in EditUser or any other component that needs to read or update the auth state.

./src/pages/EditUser.jsx

import { useContext } from "react";
import { Auth } from "../contexts/Auth";

export default function EditUser() {
  const { user, dispatch } = useContext(Auth);
  console.log("Rendering: EditUser");

  // You can read user and dispatch actions here
  // ...

  return (
    <div>
      {/* Form to edit user data */}
    </div>
  );
}

Introducing the useReducer Hook

In the previous sections, we used Context to share state, but useState was still responsible for updating that state. For more complex state transitions, useReducer provides a central place to describe how state changes in response to actions.

You can think of useReducer as a more structured version of useState. Instead of calling a setter with a new value, you dispatch actions that describe what happened, and the reducer decides how to update state.

The hook signature looks like this:

const [state, dispatch] = useReducer(reducer, initialState);

The reducer is a pure function that receives the current state and an action, then returns the next state. The auth reducer we defined earlier supports two actions:

  • "LOGIN" – updates the user state with the payload.
  • "LOGOUT" – clears the user and removes it from localStorage.

The action object always has a type field and may include a payload with extra data. This makes your state transitions easier to track and debug.

Dispatching Auth Actions from Components

Now that our auth logic is centralised in the reducer, components should dispatch actions instead of manually changing state.

Here is an example Login component that dispatches a "LOGIN" action when the user submits the form:

import { useContext, createRef } from "react";
import { Auth } from "../contexts/Auth";

export default function Login() {
  const { dispatch } = useContext(Auth);
  const email = createRef(null);
  const password = createRef(null);

  const handleLogin = async (e) => {
    e.preventDefault();

    // This is just an example – replace with a real API call
    const user = { email: email. .currentvalue };

    // Updating the global Auth context
    dispatch({ type: "LOGIN", payload: user });
  };

  return (
    <form onSubmit={handleLogin}>
      {/* Email and password inputs */}
      <button type="submit">Login</button>
    </form>
  );
}

Whenever the reducer returns a new state object, React re‑renders any component that reads from Auth. If the reducer returns the existing state unchanged, no re‑render is triggered.

Custom Hooks for Login and Logout

To keep components lean and reusable, we can encapsulate login and logout logic inside custom hooks. This is especially helpful once you add network requests and error handling.

useLogin Hook

./src/hooks/useLogin.jsx

import { useContext, useState } from "react";
import { Auth } from "../contexts/Auth";

export const useLogin = () => {
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const { dispatch } = useContext(Auth);

  const login = async (email, password) => {
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch("/api/users/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });

      const json = await response.json();

      if (!response.ok) {
        // API returned an error
        setIsLoading(false);
        setError(json);
        return;
      }

      // Save the user and token in localStorage
      localStorage.setItem("user", JSON.stringify(json));

      // Update the global Auth context
      dispatch({ type: "LOGIN", payload: json });

      setIsLoading(false);
    } catch (err) {
      console.error(err);
      setIsLoading(false);
      setError({ message: "Something went wrong. Please try again." });
    }
  };

  return { error, isLoading, login };
};

Note: More advanced users might reach for the lazy‑initialisation feature of useReducer to read from localStorage when the app first loads. Here we keep that logic in a useEffect and a custom hook to keep concerns separated and the example easier to follow.

Using useLogin in the Login Page

./src/pages/Login.jsx

import { createRef } from "react";
import { useLogin } from "../hooks/useLogin";

export default function Login() {
  const { login, isLoading, error } = useLogin();
  console.log("Rendering: Login");

  const email = createRef(null);
  const password = createRef(null);

  const handleLogin = async (e) => {
    e.preventDefault();
    await login(email.current.value, password.current.value);
  };

  return (
    <form onSubmit={handleLogin}>
      {/* Inputs */}
      <button
        type="submit"
        disabled={isLoading}
        className="btn btn-square w-full bg-gray-100 text-gray-600 hover:bg-gray-300 border-none"
      >
        {isLoading? "A moment please...": "Login"}
      </button>

      {error && (
        <span className="text-red-500 p-2">
          {error.message || "Login failed."}
        </span>
      )}
    </form>
  );
}

The component stays focused on rendering the form and handling the submit event. The hook takes care of API calls, updating context, and surfacing errors.

useLogout Hook

Logout logic can also live in its own hook. This keeps your components small and makes it easy to reuse the functionality wherever you have a Logout button.

./src/hooks/useLogout.jsx

import { useContext } from "react";
import { Auth } from "../contexts/Auth";

export const useLogout = () => {
  const { dispatch } = useContext(Auth);

  const logout = () => {
    // Delete user from localStorage
    localStorage.removeItem("user");

    // Wipe out the Auth context
    dispatch({ type: "LOGOUT" });
  };

  return { logout };
};

To use this hook, import it into UserDropdown and wire it up to a button click.

./src/components/UserDropdown.jsx

import { useContext } from "react";
import { Auth } from "../contexts/Auth";
import { useLogout } from "../hooks/useLogout";

export default function UserDropdown() {
  const { user } = useContext(Auth);
  const { logout } = useLogout();
  console.log("Rendering: UserDropdown");

  const handleLogout = () => {
    logout();
  };

  return (
    <ul>
      {/* ...other menu items... */}
      <li>
        <button onClick={handleLogout}>Logout</button>
      </li>
    </ul>
  );
}

Persisting Auth State with localStorage

You might wonder why we store the user object in localStorage. The main reason is persistence: we want the user to remain logged in if they refresh the page, as long as their token remains valid.

Without persistence, the state is lost on every full-page reload. By reading from localStorage when the app starts, we can restore the previous auth state and avoid forcing users to log in again unnecessarily.

./src/contexts/Auth.jsx

import { createContext, useReducer, useEffect } from "react";

export const Auth = createContext();

const authReducer = (state, action) => {
  switch (action.type) {
    case "LOGIN":
      Return { user: action.payload };

    Case "LOGOUT":
      localStorage.removeItem("user");
      return { user: null };

    Default:
      return state;
  }
};

export const AuthProvider = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
  });

  useEffect(() => {
    const storedUser = localStorage.getItem("user");
    if (storedUser) {
      const user = JSON.parse(storedUser);
      dispatch({ type: "LOGIN", payload: user });
    }
  }, []);

  return (
    <Auth.Provider value={{ ...state, dispatch }}>
      {children}
    </Auth.Provider>
  );
};

Security note: storing sensitive tokens in localStorage has trade‑offs, especially around XSS. For production apps, make sure you understand the security implications and consider alternatives like HTTP‑only cookies.

Protecting React Routes with Auth State

The last piece of our puzzle is navigation. We want unauthenticated users to see the login page by default, and authenticated users to see the home page instead. We can achieve this with react-router-dom and our global auth state.

./src/App.jsx

import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import "./App.css";
import Navbar from "./components/Navbar";
import Home from "./pages/Home";
import Login from "./pages/Login";
import { useContext } from "react";
import { Auth } from "./contexts/Auth";

function App() {
  const { user } = useContext(Auth);

  return (
    <BrowserRouter>
      <div className="h-screen">
        <Navbar />
        <main className="px-4">
          <Routes>
            <Route
              path="/"
              element={user? <Home /> : <Navigate to="/login" />}
            />
            <Route
              path="/login"
              element={!user ? <Login /> : <Navigate to="/" />}
            />
          </Routes>
        </main>
      </div>
    </BrowserRouter>
  );
}

export default App;

Here, user comes from the global Auth context. If user is defined, the root route (/) shows Home; otherwise, the router redirects to /login. For the login route, we invert the logic so authenticated users cannot accidentally return to the login page.

This pattern scales well as you add more protected routes. You can extract it into reusable “protected route” components, or even custom hooks, as your app grows.


Context API vs Redux Toolkit (Looking Ahead)

The Context API plus useReducer works very well for small to medium apps and for truly global state, like authentication. However, as your app grows and your state becomes more complex, you may want a dedicated state management library.

Redux has been around for many years, and Redux Toolkit (RTK) is now the officially recommended way to use Redux. RTK reduces boilerplate, guides you toward best practices, and makes features like immutable updates and async logic much easier to implement.

Historically, Redux started as a very un‑opinionated library with a tiny API, leaving app structure entirely up to developers. This flexibility often led to verbose code and many custom abstractions. Redux Toolkit adds a layer of ergonomics on top of Redux so that you can get the benefits without reinventing patterns yourself.

If you are curious about when to choose Context versus Redux Toolkit, the official Redux docs provide a deep dive into the trade‑offs and recommended use cases. You can start with the Redux Toolkit documentation.

Wrap-up

In this article, we built an authentication workflow in React using three key tools: useState, the Context API, and useReducer. You saw how a simple state lifted with props can turn into prop drilling, and how Context helps you share global data more cleanly.

We also organised our auth logic with useReducer, custom hooks, and localStorage persistence. This structure keeps your components focused on rendering, while centralising the rules for how state changes over time.

State management is one of the most important topics in modern frontend development. Once you are comfortable with these patterns, you will be well prepared to explore more advanced tools like Redux Toolkit or libraries built on top of it for large‑scale React applications.

Want to go deeper into React, JavaScript, and full‑stack development? Become a Pro with Code Labs Academy’s Online Full-Stack Developer Bootcamp.

Frequently Asked Questions

When should I use the Context API instead of props in React?

Use the Context API when several components across your tree need access to the same value, such as the authenticated user or theme, and passing it through every intermediate component would cause prop drilling. For local state that is only needed by a parent and a couple of children, lifting state and passing props is usually still the simplest option.

Do I still need Redux Toolkit if I use Context and useReducer?

Context plus useReducer is enough for many small or medium‑sized apps, especially for global concerns like authentication. Redux Toolkit becomes more attractive when you have many slices of state, complex async flows, or want powerful tooling like time‑travel debugging and predictable patterns for scaling a large codebase.

Is it safe to store my JWT or access token in localStorage?

Storing tokens in localStorage is simple and works for many demos, but it can expose your app to extra risk if an attacker can run JavaScript on your page via XSS. For production systems, discuss storage options with your security team and consider HTTP‑only cookies or other patterns recommended by your backend and security stack.

Career Services

Personalized career support to help you launch your tech career. Get résumé reviews, mock interviews, and industry insights—so you can showcase your new skills with confidence.