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
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>
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>
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.
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>
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.
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.
useSavedConcerts() is cleaner than useContext(SavedConcertsContext) everywhere.useContext directly. This gives you flexibility to change the implementation later<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>
Videos
Reading
useReducer and Context, which maps directly onto what you've built this lessonThe HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.