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:
HomepageLoginpageEditUserpageNavbarcomponentUserDropdowncomponent
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:
nameis defined inAppwithuseState.Apppassesnamedown toNavbar.Navbarpassesnameagain toUserDropdown.UserDropdownis the component that actually needs to rendername.
./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 theuserstate with the payload."LOGOUT"– clears theuserand removes it fromlocalStorage.
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
localStoragehas 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.