Week 9

useReducer

Context API

Error Boundaries

React Hook Form

Practice

Assignment

Front end Track

Context API

What is the Context API?

So far, when a child component needs data, you pass it as a prop. That works well for one or two levels. But imagine your app has a ConcertList component that renders ConcertCard, which renders TicketButton, which needs to know the currently logged-in user to check if they've already bought a ticket. Passing user through every component in that chain โ€” even the ones that don't care about it โ€” is called prop drilling, and it gets painful fast.

The Context API is React's built-in solution. It lets you make a value available to any component in a part of your tree, without threading it through every layer as a prop. Think of it like a shared noticeboard: you pin something to it once, and any component that knows to look at the noticeboard can read it directly.

https://www.youtube.com/watch?v=n7xQVRpYHYY

Creating and Providing Context

Context is created outside of any component with createContext. You then use a Provider component to make the value available to everything rendered inside it.

import { createContext, useState } from 'react';

// 1. Create the context
// The argument to createContext is the default value โ€”
// used only when a component reads the context outside of any Provider
export const UserContext = createContext(null);

// 2. Create a Provider component that wraps part of your tree
export function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  return (
    // 3. Pass the value you want to share
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

Then in your app, wrap the part of the tree that needs access to this value:

import { UserProvider } from './UserContext';
import { ConcertList } from './ConcertList';

function App() {
  return (
    <UserProvider>
      <ConcertList />
    </UserProvider>
  );
}

Everything rendered inside UserProvider can now access the user, no matter how deep in the tree!

<aside> ๐Ÿ’ก

Info

The default value you pass to createContext is only used when a component calls useContext outside of a matching Provider. In practice this mostly catches mistakes during development. Setting it to null and checking for it in your consumer is a reasonable pattern.

</aside>

Consuming Context with useContext

Any component inside the Provider can read the context value using the useContext hook. No props needed:

import { useContext } from 'react';
import { UserContext } from './UserContext';

function TicketButton({ concertId }) {
  // Read from context directly โ€” no prop needed
  const { user } = useContext(UserContext);

  if (!user) {
    return <button disabled>Log in to buy tickets</button>;
  }

  return (
    <button onClick={() => purchaseTicket(user.id, concertId)}>
      Buy Ticket
    </button>
  );
}

TicketButton doesn't care how many components sit between it and the UserProvider. It just reads what it needs.

<aside> โŒจ๏ธ

Hands On

Create a small React app with three components nested inside each other: App โ†’ Section โ†’ Heading. Use createContext to create a ThemeContext that holds either 'light' or 'dark'. Provide it at the App level and consume it in Heading to change the background colour. Section should not receive any props โ€” it just renders Heading. Confirm that Heading can read the theme without Section passing anything down.

</aside>

Context + useReducer: The Real Pattern

useContext on its own is great for simple shared values โ€” a theme, a locale, a logged-in user. But when shared state gets more complex, with multiple fields and meaningful transitions, you'll want to combine it with useReducer.

This is a pattern you'll see in a lot of professional React codebases. The idea is straightforward: use useReducer to manage your state, and use Context to make both the state and the dispatch function available to the rest of the tree.

Here's what that looks like for a concert app with a shared list of saved concerts:

import { createContext, useContext, useReducer } from 'react';

// --- State shape and reducer ---

const initialState = {
  savedConcerts: [],
  filter: 'all',
};

function savedConcertsReducer(state, action) {
  switch (action.type) {
    case 'SAVE_CONCERT':
      return {
        ...state,
        savedConcerts: [...state.savedConcerts, action.payload],
      };
    case 'REMOVE_CONCERT':
      return {
        ...state,
        savedConcerts: state.savedConcerts.filter(
          (c) => c.id !== action.payload
        ),
      };
    case 'SET_FILTER':
      return {
        ...state,
        filter: action.payload,
      };
    default:
      return state;
  }
}

// --- Context setup ---

// Separate contexts for state and dispatch โ€” more on why below
const SavedConcertsStateContext = createContext(null);
const SavedConcertsDispatchContext = createContext(null);

// --- Provider ---

export function SavedConcertsProvider({ children }) {
  const [state, dispatch] = useReducer(savedConcertsReducer, initialState);

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

// --- Custom hooks for clean consumption ---

export function useSavedConcerts() {
  return useContext(SavedConcertsStateContext);
}

export function useSavedConcertsDispatch() {
  return useContext(SavedConcertsDispatchContext);
}

Now any component in the tree can read state or dispatch actions:

import { useSavedConcerts, useSavedConcertsDispatch } from './SavedConcertsContext';

function SaveButton({ concert }) {
  const { savedConcerts } = useSavedConcerts();
  const dispatch = useSavedConcertsDispatch();

  const isSaved = savedConcerts.some((c) => c.id === concert.id);

  function handleClick() {
    if (isSaved) {
      dispatch({ type: 'REMOVE_CONCERT', payload: concert.id });
    } else {
      dispatch({ type: 'SAVE_CONCERT', payload: concert });
    }
  }

  return (
    <button onClick={handleClick} aria-pressed={isSaved}>
      {isSaved ? 'Saved' : 'Save'}
    </button>
  );
}

Notice the custom hooks useSavedConcerts and useSavedConcertsDispatch. You don't have to write them โ€” you could call useContext directly every time โ€” but custom hooks make the consumer code much cleaner and hide the implementation detail of which context is being used.

Avoiding Unnecessary Re-renders

Every component that calls useContext(SomeContext) will re-render whenever the context value changes. This is expected behaviour โ€” but it can cause performance problems if your context value is a large object that changes frequently.

The most common mistake is putting state and dispatch together in a single context object:

// โš ๏ธ Components that only need to dispatch will still re-render
// whenever state changes, because the value object is recreated
<ConcertsContext.Provider value={{ state, dispatch }}>

The fix is what you already saw above: separate contexts for state and dispatch. dispatch is stable โ€” React guarantees it never changes. Components that only need to dispatch will never re-render due to state changes:

// โœ… Components consuming only dispatch never re-render on state change
<StateContext.Provider value={state}>
  <DispatchContext.Provider value={dispatch}>
    {children}
  </DispatchContext.Provider>
</StateContext.Provider>

A second approach is to split large context into smaller, more focused contexts. Instead of one AppContext holding everything, have a UserContext, a ConcertsContext, and a UIContext. Components only subscribe to what they need.

<aside> โš ๏ธ

Warning

Don't reach for Context to solve every prop-drilling situation. If you're only passing a prop through one or two levels, prop drilling is fine โ€” it's explicit and easy to trace. Context hides data flow, which makes components less portable and bugs harder to track down. Use it when the value is genuinely needed across many components at different levels of the tree.

</aside>

What Context is Not

Context is not a replacement for a state management library, and it's not a performance optimisation tool. It's a mechanism for making values available across a component tree without prop drilling. For state that changes very frequently (like mouse position or scroll position updating dozens of times per second), Context is likely the wrong choice โ€” the re-renders it triggers will be too costly.

For the kinds of state you'll be managing in this curriculum โ€” user sessions, theme preferences, shared app data โ€” Context is exactly the right tool.

A Note on File Organisation

As your contexts grow, a consistent file structure keeps things manageable. A common pattern:

src/
  context/
    SavedConcertsContext.jsx   // createContext, Provider, custom hooks
    UserContext.jsx

Each context file exports:

This keeps the messy context wiring in one place and gives consumers a clean API.

Best Practices

<aside> ๐ŸŽ‰

Celebration

You now understand one of the most important patterns in React development. The Context + useReducer combination is the foundation that libraries like Redux are built on โ€” and you've built it from scratch. Any time you see a complex React app with shared state, you'll recognise the shape of what's happening under the hood.

</aside>

Additional Resources

Videos

Reading


The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.