Lesson 6 · Hooks in depth
Context
The end of prop drilling, the Provider + hook pattern and performance traps.
Loading lesson…
Lesson 6 · Hooks in depth
The end of prop drilling, the Provider + hook pattern and performance traps.
Loading lesson…
The head teacher needs to tell the pupils on the third floor that PE is cancelled this afternoon. Without a PA system it would go like this: the head tells the deputy, the deputy tells a teacher in the corridor, that teacher tells the class teacher, and the class teacher finally tells the pupils. The deputy and the teacher in the corridor don't need the message at all – they only have to pass it on. And if anyone in the chain forgets, the message is lost.
In React this is called prop drilling. Data flows only from the top down through props, so when a component deep in the tree needs it, every component in between has to carry it – even the ones that don't use it at all.
A school solves this with a PA system. The head speaks into the microphone in the office and everyone in the building with a loudspeaker hears it. Nobody carries anything. That's exactly what Context is:
An app has a signed-in user. Their avatar is shown in the sidebar, which is inside the layout, which is inside the app:
function App() {const [user, setUser] = useState<User>(…)return <Layout user={user} />}function Layout({ user }: { user: User }) { // doesn't need it, just passes it onreturn <Sidebar user={user} />}function Sidebar({ user }: { user: User }) { // doesn't need it, just passes it onreturn <Avatar user={user} />}function Avatar({ user }: { user: User }) { // finally uses itreturn <img src={user.photo} alt={user.name} />}
Why it's a problem:
Layout and Sidebar have to know the User type and have a prop they don't need.Two or three levels are fine – props are clear and readable. The trouble starts when the same data is carried through many layers to many places.
We'll build a PA system for a colour theme (light/dark) that lots of components want to know about.
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null)
createContext creates a "channel". The value in the brackets (null) is what a component hears when it's out of range of any Provider – a loudspeaker with no signal.
function ThemeProvider({ children }: { children: ReactNode }) {const [theme, setTheme] = useState<Theme>('light')const toggle = () => setTheme((t) => (t === 'light' ? 'dark' : 'light'))return <ThemeContext value={{ theme, toggle }}>{children}</ThemeContext>}// Somewhere at the top of the app:<ThemeProvider><Layout /></ThemeProvider>
<ThemeContext value={…}> is how you write a Provider in React 19. In older code you'll see <ThemeContext.Provider value={…}>.children and their descendants.function useTheme() {const ctx = use(ThemeContext)if (!ctx) throw new Error('useTheme must be inside <ThemeProvider>')return ctx}// Anywhere deep in the tree:function ThemedButton() {const { theme, toggle } = useTheme()return <button className={theme} onClick={toggle}>Toggle</button>}
Why a custom hook and not use(ThemeContext) directly in every component? When someone forgets to wrap the app in the Provider, without the check they'd get null and, a few lines later, a confusing "cannot read properties of null" error. With the hook they get a clear message about what's wrong. On top of that, components don't need to know how the context is built inside.
toggle() calls setTheme in the Provider. The Provider's state has changed.{ theme: "dark", toggle }.Layout between them doesn't have to re-render because of the context – it reads nothing from it.What you see: A small app: Layout contains Sidebar and a "Content" panel. The panels and the button read the theme with useTheme(). Neither Layout nor Sidebar gets any theme prop.
Try it:
Layout component. It doesn't contain a word about the theme – and yet both its parts are in sync.The takeaway: One value in the Provider's state, many listeners anywhere below it, and nobody in between has to carry it.
Loading the interactive part…
The PA system is for things the whole school cares about: the end of lessons, a fire alarm. If it were used to sort out who lent whom a rubber, nobody would hear a thing. Context suits data that many places need and that changes rarely.
| ✅ A good candidate | ❌ A bad candidate |
|---|---|
| Theme, language, currency format | Data that changes on every keystroke |
| The signed-in user, permissions | Server data (that’s a job for TanStack Query) |
| Services: toasts, modals, analytics | State that can just be passed 1–2 levels down as props |
| The state of a complex widget (Tabs, Accordion) shared between its parts | A global "store for everything" |
Sometimes the problem is that the message is carried by people who don't need to carry it at all. Instead of a PA system, it's enough to seat the pupil right in the head's office. In React: instead of pushing data through Layout, give it a ready-made element that already has the data:
// ❌ Layout carries user only to pass it on<Layout user={user} />// ✅ You create Avatar where user is. Layout only decides WHERE it shows up.<Layout sidebar={<Avatar user={user} />} />function Layout({ sidebar }: { sidebar: ReactNode }) {return <div className="layout"><aside>{sidebar}</aside>…</div>}
Layout now knows nothing about the user – and it doesn't need Context for that either. These are the slots from the lesson on the basics.
The PA system has one drawback: when the announcement changes, everyone has to listen, even if only a bit of it concerns them. "PE is cancelled and there's pizza in the canteen" – and because of the pizza, even the class that has no PE today pricks up its ears.
Context works the same way. When the Provider broadcasts a new value (React tells by comparing the reference), everyone who reads the context re-renders – even those who use only part of the value.
function AppProvider({ children }: { children: ReactNode }) {const [count, setCount] = useState(0)// Every change of count creates a NEW object → all listeners re-renderreturn <AppContext value={{ user: 'Anna', count, inc }}>{children}</AppContext>}function UserBadge() {const { user } = use(AppContext) // reads only user…return <span>{user}</span> // …but re-renders on every change of count too}
The solution: instead of one channel, use several channels – a context for each thing that changes separately.
What you see: Two identical pairs of components: the user's name and a counter. On the left both read from one context, on the right from two separate ones. Each card flashes yellow when it re-renders. All of them are wrapped in memo, so they don't re-render because of the parent.
Try it:
memo didn't help the name card on the left. A context change goes around it.The takeaway: Every listener re-renders on every change of its channel. Put things that change independently into separate contexts.
Loading the interactive part…
A common and very effective pattern is to combine Context with the reducer from the previous lesson. One channel broadcasts the state, another the dispatch function. dispatch never changes, so components that only send actions ("Add" buttons) don't re-render when the data changes:
const TasksContext = createContext<Task[] | null>(null)const TasksDispatchContext = createContext<Dispatch<Action> | null>(null)function TasksProvider({ children }: { children: ReactNode }) {const [tasks, dispatch] = useReducer(tasksReducer, [])return (<TasksContext value={tasks}><TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext></TasksContext>)}function AddTaskButton() {const dispatch = useTasksDispatch() // listens only to the channel that never changesreturn <button onClick={() => dispatch({ type: 'added', text: 'New' })}>Add</button>}
Read the value from the context – without passing props through Middle.
CHALLENGE: A colour from context
The context with the colour is created and App provides it (value="tomato").
1. In the ColoredText component, read the colour: const color = use(ColorContext)
and use it in the style: style={{ color }}
ColoredText gets no props – it takes the colour from the context itself.row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
The first challenge practises section 2 – the whole Provider + hook pattern. The second practises section 4: context as a "service" that broadcasts only a function, so the components that trigger notifications don't re-render.
The complete Provider + hook pattern + content by role. The Page component in between mustn't know anything about the user – just like the deputy, who no longer has to carry the message.
CHALLENGE: Signing in through Context
The Header, SecretContent and AdminPanel components are finished and take their data from useAuth().
You'll write the Provider and the hook so that it really works.
1. AuthProvider: create a `user` state (User | null, starts as null) and build `value`:
login(name) → setUser({ name, role: name === 'admin' ? 'admin' : 'user' })
logout() → setUser(null)
2. AuthProvider: wrap `children` in <AuthContext value={value}>.
3. useAuth(): read the context with use(AuthContext). When it's null (the hook is outside the Provider),
throw a clear error: throw new Error('useAuth() must be inside <AuthProvider>').
Notice: Page, between AuthDemo and Header, gets no props about the user.row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
Context as a "service" – exactly how toasts work in libraries like sonner or react-hot-toast.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
use(Ctx) listens, the components in between know nothing.createContext(null) + a Provider with state + a hook with a check. In React 19 <Ctx value> and use(Ctx).memo. Split contexts into several channels and separate the state from dispatch.