Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 82

React Context for State Management

Recommended resources

Hello! In our previous lessons, you've successfully implemented a full authentication flow, complete with login/registration, protected routes, and a UI that dynamically changes based on the user's login status. You achieved this using an AuthContext and a useAuth hook, which have been acting as the central nervous system for your app's user data.

Today, we're going to pull back the curtain and explore how this system works from the ground up. You'll learn how to build your own context to manage application-wide state effectively. This will not only solidify your understanding of authentication but also empower you to manage other types of global state you might need in future applications, like themes, language settings, or shopping cart data.

By the end of this lesson, you will be able to:

  • Explain the problem of "prop drilling" and why React Context is the solution.
  • Create a context from scratch using createContext, a stateful Provider, and the useContext hook.
  • Implement best practices, such as creating a custom hook for consuming context.
  • Critically decide what state belongs in a global context versus what should remain as local component state.

The Problem: Prop Drilling

Imagine you have a user object at the top of your application in App.jsx. Now, a deeply nested component, say <ProfileAvatar />, needs to display the user's name. Without a state management tool, you would have to pass the user object as a prop through every single component in the chain between App and ProfileAvatar.

// App.jsx
function App() {
  const [user, setUser] = useState({ name: 'Alex' });
  return <Layout user={user} />;
}

// Layout.jsx
function Layout({ user }) {
  return <Header user={user} />;
}

// Header.jsx
function Header({ user }) {
  return <UserInfo user={user} />;
}

// UserInfo.jsx
function UserInfo({ user }) {
  // Finally, we can use it!
  return <span>{user.name}</span>;
}

This is called prop drilling. The components in the middle (Layout, Header) don't actually need the user prop for themselves; they just act as conduits. This makes code harder to read, refactor, and maintain.

React's Context API is the built-in solution to this problem. It allows you to create a "global" state container that any component in the tree below it can access directly, without props being passed down manually.

State Management in React | Context API useContext | React Tutorials for Beginners

To start, watch the first minute of this video from Dave Gray. He gives a great visual explanation of the prop drilling problem we're about to solve.

Watch from the beginning until 01:05. Focus on understanding the concept of drilling props down through components and why it can become 'unwieldy'.


Building an Authentication Context

Let's build a simple authentication context from scratch. This process involves three key steps:

  1. Create the context.
  2. Provide the context's value at the top of your component tree.
  3. Consume the context's value in any child component.

This hands-on video will walk you through building a complete authentication context, which is exactly what we need for our application.

React Context Api with user Auth Project | useContext hook | React Project

This video by The Humble Coder demonstrates building a user authentication context from scratch. We will follow its steps to understand the core mechanics.

Follow along with the video, pausing to absorb each step: Creating the Context and Provider (03:38 - 05:41): Pay attention to how createContext is used to initialize the context and how the AuthProvider component is created. Inside AuthProvider, notice the use of useState to manage state (username, password, user) and how these values are passed into the AuthContext.Provider's value prop. Wrapping the App (Implied Step): At 05:24, the video wraps the App component with the AuthProvider in index.js. This is the crucial step that makes the context available to the entire application. Consuming the Context (06:06 - 07:19): See how the LoginPage uses the useContext hook to pull the state and setter functions directly from the context, connecting the input fields to the global state. Adding Logic (08:44 - 14:15): Observe how login and logout functions are defined within the AuthProvider and passed down through the context's value. This centralizes the authentication logic, keeping components clean.

After watching, let's review the core structure of our new context file, let's call it src/contexts/AuthContext.jsx:

import React, { useState, createContext } from 'react';

// 1. Create the Context
const AuthContext = createContext();

// 2. Create the Provider Component
const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  // In a real app, you'd also manage a token
  // and likely sync it with localStorage.

  const login = (userData) => {
    // In a real app, this would involve an API call
    setUser(userData);
  };

  const logout = () => {
    // Clear user from state
    setUser(null);
  };

  // The value object contains what we want to expose to consumers
  const value = {
    user,
    login,
    logout,
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};

// 3. (In App.js) Wrap your component tree
// <AuthProvider>
//   <App />
// </AuthProvider>

// 4. (In a child component) Consume the context
// import { useContext } from 'react';
// import { AuthContext } from './AuthContext';
// const { user, logout } = useContext(AuthContext);
Integrating AuthProvider with React Context
This image illustrates the key step of wrapping your application's components with the `AuthProvider`, making the context's value accessible to all descendants.

Best Practice: The Custom Hook Pattern

While the above works perfectly, there's a widely-adopted pattern that makes using your context cleaner, more maintainable, and less error-prone. Instead of exporting the AuthContext object and having every component import both useContext and AuthContext, we can create a single custom hook that bundles this logic.

How to use React Context effectively

The renowned React educator Kent C. Dodds wrote a fantastic article on this topic. We'll focus on the section that introduces the custom hook pattern.

Read the section titled 'The Custom Consumer Hook'. Notice how he transforms the consumer's code from using React.useContext(SomeContext) to a simpler useSomething(). Pay close attention to the implementation of the useCount hook, especially the check for context === undefined. This is a powerful technique for ensuring the hook is used correctly.

Let's apply this pattern to our AuthContext.jsx. We'll stop exporting AuthContext and instead export a custom useAuth hook.

// src/contexts/AuthContext.jsx (Updated)
import React, { useState, useContext, createContext } from 'react';

// Context is now created but NOT exported
const AuthContext = createContext();

// Custom hook for consuming the context
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

// Provider component remains the same, but is now the primary export
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  const login = (userData) => setUser(userData);
  const logout = () => setUser(null);

  const value = { user, login, logout };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};

Now, any component that needs the auth state can do this:

// src/components/Header.jsx
import { useAuth } from '../contexts/AuthContext'; // Just one import!

const Header = () => {
  const { user, logout } = useAuth(); // Cleaner and more descriptive

  return (
    <header>
      {user ? (
        <button onClick={logout}>Logout</button>
      ) : (
        <span>Please log in.</span>
      )}
    </header>
  );
};

This pattern gives us:

  • Simpler API: Consumers only need to know about useAuth().
  • Encapsulation: The inner workings (useContext, AuthContext) are hidden.
  • Error Prevention: The hook will throw an error if you forget to wrap your component in the AuthProvider, saving you debugging time.

What Belongs in Context? (And What Doesn't)

A common mistake is to put all shared state into a single, massive context. This can lead to performance issues, as any component subscribed to the context will re-render whenever any value in the context changes, even if that component doesn't use the specific piece of state that changed.

Rule of Thumb: Use Context for state that is truly global and changes infrequently.

  • Good candidates for Context: User authentication data, application theme (dark/light), selected language.
  • Bad candidates for Context: State for a single form's input fields, the open/closed state of a single modal, data fetched and used by only one part of the app.

State Management in React | Context API useContext | React Tutorials for Beginners

Let's return to Dave Gray's video. He does an excellent job demonstrating how to refactor an application to determine which state should be in context and which should be local.

Watch from 23:33 to 39:16. This is a masterclass in refactoring. Dave identifies state that is only used by a single component (like the state for a 'new post' form or an 'edit post' form) and moves it out of the global context and into the component that uses it. This process is called 'co-locating' state and is crucial for performance and maintainability.

The key takeaway is to be deliberate. Just because state is shared between a parent and a child doesn't mean it needs to go into a global context. Sometimes, simple prop passing is still the right answer. Reserve Context for state that needs to be accessed by many components at different levels of the tree.

Test your understanding!

In your task manager application, you have the following pieces of state:

  1. The currently logged-in user object.
  2. The list of tasks fetched from the API.
  3. The text typed into the "Add New Task" input field.
  4. An isLoading boolean that shows a spinner while tasks are being fetched.

Which of these belong in a global context (AppContext or AuthContext), and which should be managed as local state within a component?

Show answer
  1. User Object: Definitely belongs in a global AuthContext. It's needed by many components (Header, ProtectedRoute, pages that fetch user-specific data) across the entire app.
  2. List of Tasks: This is a tricky one. If many different components across different pages need to display or interact with the tasks, a TasksContext could be a good idea. However, if only the DashboardPage and its children use this list, it might be better to fetch and manage this state within DashboardPage itself to keep the state co-located.
  3. "Add New Task" Input Text: This is classic local state. Only the form component cares about this text before it's submitted. It should be managed with useState inside the form component. Putting it in a global context would cause unnecessary re-renders across your app every time the user types a character.
  4. isLoading Spinner State: This should be co-located with the logic that performs the data fetching. If you fetch tasks in DashboardPage, the isLoading state for that fetch should also live in DashboardPage.

Conclusion

You've done a fantastic job diving deep into React's Context API. You now have a robust mental model for managing application-wide state, which is a fundamental skill for any React developer.

Key Takeaways:

  • React Context solves the "prop drilling" problem by providing a direct channel for state to components deep in the tree.
  • The core pattern involves creating a context, wrapping your app in a Provider that holds the state and logic, and consuming it with useContext.
  • Encapsulating your context logic within a custom hook (useAuth) is a powerful best practice that simplifies usage and prevents errors.
  • Effective state management is about being selective. Keep state as local as possible, and only elevate it to a context when it's truly global.

Preview of the Next Lesson:
We now have a deep understanding of our authentication context. The final piece of the puzzle is to make our data user-specific. In the next lesson, you will integrate authentication with your CRUD operations. We'll modify the backend to associate tasks with users and update the frontend API calls to use the authentication token, ensuring that users can only create, read, update, and delete their own tasks.