Module 4: Hooks and Lifecycles
Context: Sharing Data Without Prop Drilling
How createContext, Provider and useContext work, how updates propagate, and where context stops being the right tool.
Props are great until a value has to travel through five components that don't care about it, just to reach the one that does. Things like the current theme, the logged-in user, or the language setting are needed all over the place. Context lets any component read those without every layer passing them along.
The three pieces#
import { createContext, useContext, useState } from "react";
// 1. Create it
const ThemeContext = createContext("light");
// 2. Provide it
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext value={{ theme, setTheme }}>
<Page />
</ThemeContext>
);
}
// 3. Consume it
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Current: {theme}
</button>
);
}
In React 19 you render the context object itself as the provider. In React 18 and earlier you'd write <ThemeContext.Provider value={...}>. That older form still works, so you'll see it everywhere.
useContext looks upward from the component and returns the value of the nearest matching provider. Any component under <ThemeContext value> can read it, no matter how deep.
App ← <ThemeContext value={{theme: "dark"}}>
│
Page
/ \
Header Content
│ │
ThemeToggle Card ── useContext(ThemeContext) ← all of these see "dark"
The default value#
createContext("light") sets a default. It's used only when a component reads the context and there's no provider above it. It is not the initial state. Many people expect the default to show up after useState changes, and that doesn't happen.
How updates travel#
When the provider's value changes, React re-renders every component that reads that context, wherever they are, even if some parent in between is memoized. Comparison is by reference.
That has a consequence worth understanding. In the example above, value={{ theme, setTheme }} creates a new object on every render of App. So every consumer re-renders whenever App does, even if theme didn't change. For a theme, who cares. For a context that sits at the root of a large app and has many consumers, it can add up.
A common remedy is to hold the value in a dedicated provider component, and memoize the object:
import { createContext, useContext, useMemo, useState } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext value={value}>{children}</ThemeContext>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (ctx === null) {
throw new Error("useTheme must be used inside <ThemeProvider>");
}
return ctx;
}
Several things are happening in that small file, and they're all habits worth copying.
- The state lives in a provider component, not in
App.Appstays clean, andchildrenpassed through means that when the provider's state changes, only consumers re-render, not the whole subtree that was passed in as children. - The context has
nullas its default, and theuseThemehook throws if there's no provider. A loud error is far better than silently getting the wrong default. - Components import
useTheme, never the raw context. If you later change how theme is stored, only this file changes.
Usage:
// main.jsx
<ThemeProvider>
<App />
</ThemeProvider>
// anywhere
const { theme, setTheme } = useTheme();
Split contexts that change at different rates#
If you put user, theme and cart into one big context, a cart update re-renders everything that reads the theme. Keep contexts small and focused. Another useful split is separating the state from the functions that update it, since setters usually don't change:
const CountStateContext = createContext(0);
const CountDispatchContext = createContext(() => {});
A component that only dispatches (a button) then doesn't re-render when the count changes. This pairs naturally with useReducer, which we cover next.
Reading context conditionally#
React 19 adds a use function that can read context too, and unlike useContext it can be called after an early return or inside an if.
import { use } from "react";
function Heading({ children }) {
if (children == null) return null;
const theme = use(ThemeContext);
return <h1 className={theme}>{children}</h1>;
}
useContext is still perfectly fine. You'll see it in most codebases for years.
What context is not#
Context is a delivery mechanism, not a state manager. It doesn't store anything. The state still lives in a useState or useReducer somewhere, and context just makes it reachable from far away.
It also has no built-in way to subscribe to a slice of the value. If a component reads the context, it re-renders when any part of the value changes. For frequently changing, large shared state, that's where libraries like Zustand and Redux Toolkit become useful, which is module 6.
Good fits for context: theme, locale, the current user, feature flags, a form's shared state inside one big component. Bad fit: a value that changes many times a second and is read by hundreds of components.
Where people get burned#
- Treating the default value as the initial state.
- Creating the provider's value inline (
value={{ a, b }}) in a component that re-renders often. - Putting everything into one giant context.
- Reading context in a component that isn't wrapped in the provider, getting
null, and then crashing on destructure. This is exactly why the throwing custom hook is worth having. - Using context for state that lives and dies within one component. Plain props are simpler.
Practice#
- Build a
ThemeProvideranduseThemeas above. Make a toggle button and a page whose background color changes with the theme. - Add an
AuthProviderthat stores{ user, login, logout }and show a "Hello, name" greeting in the navbar and a protected message in the page body. - Log each time a consumer renders. Change the unrelated state in the parent of the provider and see which components re-render, with and without
useMemoon the value. - Try reading the context outside any provider and confirm your custom hook throws a clear message.