The provider pattern: context plus a guarded hook
Context carries the value, the hook guards the read, and the provider's prop is where tests swap the implementation.
Predict first
Before you read: predict
ExamHall's countdown. ErrorBoundary is a plain class error boundary. Development build, no StrictMode.
The exam clock passes 2, then 1, then 0 as secondsLeft.
What does the candidate see once it has passed 0?
const SecondsLeftContext = createContext(null); function useSecondsLeft() { const seconds = useContext(SecondsLeftContext); if (!seconds) throw new Error('useSecondsLeft must be used inside <DeadlineProvider>'); return seconds;}function DeadlineProvider({ secondsLeft, children }) { return <SecondsLeftContext value={secondsLeft}>{children}</SecondsLeftContext>;}function Countdown() { return <p>{useSecondsLeft()} s left</p>;} // the page<ErrorBoundary fallback={<p>Timer unavailable</p>}> <DeadlineProvider secondsLeft={secondsLeft}> <Countdown /> </DeadlineProvider></ErrorBoundary>The 30-second version
A provider module holds three parts: a context created with null, a provider component that fills it, and a hook that reads it and throws when it gets null. The hook makes a missing provider fail at once, by name. The provider takes its implementation as a prop, so a test passes a fake.
Context carries the value, the hook guards the read, and the provider's prop is where tests swap the implementation.
How it works
Step 1 of 10
null-default context, its provider and a useX() hook; export the last two. Each createContext call makes a new context.In the kitchen
- Each section of overhead wire is fed by its substation, and every tram under it draws from it → A provider fills the context for every reader below (step 1)
Analogy: A tramway's overhead wire
| On the tramway | In React |
|---|---|
| Each section of overhead wire is fed by its substation, and every tram under it draws from it | A provider fills the context for every reader below (step 1) |
| A tram with a line-voltage interlock that finds no wire stops and shows "no supply" | The hook throws, naming itself and the provider (step 2) |
| A battery tram rolls on under a gap in the wire, and nobody notices until the battery is flat | A live default hides a missing provider (step 3) |
In real terms: the hook reads whatever the nearest provider above supplies, and refuses to run when nothing does.
Breaks down when: a tram's draw lowers the voltage for the next tram, but reading a context uses nothing up. Wrong inference: "many readers wear a provider down".
Contrasting case: a diesel bus, which carries its own engine: no wire, no interlock, and nothing for the depot to swap. That is a client imported from a module (ng2r-di-to-context step 12).
Examples, run for real
4.1 ExamHall EXH-4130 and MR !4430: a test that passed while calling the live API
ExamHall created ApiClientContext with the live httpClient as its default, and QuestionList read it with plain useContext. A component test forgot the provider, passed, and sent a live request on every run (step 3). MR !4430 makes the default null, adds the guarded useApiClient(), and gives ApiClientProvider a client prop with httpClient as its default (steps 2, 7).
4.1 EXH-4130 createContext(httpClient), useContext | test without a provider: passes · live requests 1
4.1 MR !4430 null default + useApiClient guard | test without a provider: fails "useApiClient must be used inside <ApiClientProvider>" · live requests 0
4.1 MR !4430 client prop, httpClient default | test in <ApiClientProvider client={fakeClient}>: passes · fake requests 1 · live requests 0
4.1 deadline in its own provider, one tick | Countdown 1 · QuestionList 0
The forgetful test now fails with a message that says what to add. The deadline sits in its own provider, so a tick re-renders only the countdown (step 10).
4.2 Ticketing: React 19's provider
The seat map reads the box office client. React 19 renders the context itself as the provider; the older form still works (step 9).
4.2 <SeatApiContext.Provider value={api}> | seat map reads box-office · warnings 0
4.2 <SeatApiContext value={api}> (React 19) | seat map reads box-office · warnings 0
4.3 Ticketing: a provider that reads another provider
HoldTimerProvider reads the hold expiry through useSeatApi(). Wrapped around SeatApiProvider, its own call looks upward and finds nothing, because the provider it renders sits below it; the guard throws. Nested inside SeatApiProvider, it reads the client (step 6).
4.3 <HoldTimerProvider><SeatApiProvider> | fails "useSeatApi must be used inside <SeatApiProvider>"
4.3 <SeatApiProvider><HoldTimerProvider> | seat map reads box-office · hold until 19:05
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A ticket site's seat API module after a refactor; liveSeatApi is the box office's live client. Development build, no StrictMode.
A new component test renders <HoldButton seat="B7" /> with no provider, clicks Hold B7 once, and checks only that the button is still on screen.
What happens when the test runs?
const SeatApiContext = createContext(liveSeatApi); function SeatApiProvider({ client, children }) { return <SeatApiContext value={client}>{children}</SeatApiContext>;}function useSeatApi() { const api = useContext(SeatApiContext); if (api === null) throw new Error('useSeatApi must be used inside <SeatApiProvider>'); return api;}function HoldButton({ seat }) { const api = useSeatApi(); return <button onClick={() => api.hold(seat)}>Hold {seat}</button>;}Question 2 of 7
The seat API provider builds its own client. createLiveSeatApi() opens a connection to the box office. Development build, no StrictMode.
The HoldButton test wants its click on Hold B7 to reach fakeSeatApi, with no live client created during the test. The app renders <SeatApiProvider> with no props and re-renders it on every hold, and must still create one live client in its life.
Which single change does both?
const SeatApiContext = createContext(null);export function SeatApiProvider({ children }) { const [api] = useState(() => createLiveSeatApi()); return <SeatApiContext value={api}>{children}</SeatApiContext>;}// useSeatApi() reads SeatApiContext and throws when it gets nullQuestion 3 of 7
The seat API module with a null default, and a page built on it. Development build, no StrictMode.
A new build names the key seatsApi in config, so config.seatApi is undefined. A buyer opens the page and clicks Hold B7.
What happens?
const SeatApiContext = createContext(null);function SeatApiProvider({ client, children }) { return <SeatApiContext value={client}>{children}</SeatApiContext>;}function useSeatApi() { const api = useContext(SeatApiContext); if (api === null) throw new Error('useSeatApi must be used inside <SeatApiProvider>'); return api;}function HoldButton({ seat }) { const api = useSeatApi(); return <button onClick={() => api.hold(seat)}>Hold {seat}</button>;}function App({ config }) { return ( <SeatApiProvider client={config.seatApi}> <HoldButton seat="B7" /> </SeatApiProvider> );}Question 4 of 7
Two ways to swap the seat API in tests.
One test file runs two tests in order, in one process. Test 1 swaps in fakeA (Version 1: setSeatApi(fakeA); Version 2: it renders the button inside <SeatApiProvider client={fakeA}>) and clicks Hold B7. Test 2 swaps nothing: it renders <HoldButton seat="C3" /> on its own and clicks Hold C3.
Where does test 2's hold go, in each version?
// Version 1: seat-api.jslet current = liveSeatApi;export const setSeatApi = (api) => { current = api; };export const getSeatApi = () => current;// HoldButton's click calls getSeatApi().hold(seat) // Version 2: SeatApiContext, SeatApiProvider, the guarded useSeatApi()// and HoldButton as in P3Question 5 of 7
Write api-client.jsx from a blank file, with two exports and nothing else. ApiClientProvider takes client (optional) and children. Given client, it provides it and creates no live client; without it, it creates one live client with createHttpClient() (import it from ./http-client.js) for its whole life. useApiClient() returns the provided client, and throws an Error whose message names <ApiClientProvider> when no provider is above.
Export ApiClientProvider and useApiClient.
Question 6 of 7
A venue's seat count. Development build, no StrictMode.
A design-review page renders <SeatCount /> on its own, with no provider.
What does the page report?
const VenueContext = createContext(); function useVenue() { const venue = useContext(VenueContext); if (venue === null) throw new Error('useVenue must be used inside <VenueProvider>'); return venue;}function SeatCount() { const { seats } = useVenue(); return <p>{seats} seats</p>;}Question 7 of 7
The seat API parts come from a factory, called once by the button's module and once by the test helpers. Development build, no StrictMode.
A test mounts withSeatApi(<HoldButton seat="C3" />, fakeSeatApi) and clicks Hold C3.
What happens?
// seat-api.jsxfunction makeSeatApi() { const SeatApiContext = createContext(null); function SeatApiProvider({ client, children }) { return <SeatApiContext value={client}>{children}</SeatApiContext>; } function useSeatApi() { const api = useContext(SeatApiContext); if (api === null) throw new Error('useSeatApi must be used inside <SeatApiProvider>'); return api; } return { SeatApiProvider, useSeatApi };} // hold-button.jsxconst { useSeatApi } = makeSeatApi();function HoldButton({ seat }) { const api = useSeatApi(); return <button onClick={() => api.hold(seat)}>Hold {seat}</button>;} // test-utils.jsxconst { SeatApiProvider } = makeSeatApi();function withSeatApi(ui, client) { return <SeatApiProvider client={client}>{ui}</SeatApiProvider>;}Go deeper
Pro tips
- Render
<Context value>and memoise an object value. In React 19 the context itself is the provider. A value written inline as{ client, user }is a new object on every parent render;useMemokeeps it the same. When: any provider whose value is an object. Verify (RAN,tips.mjs): the first line below. - Name the hook and the provider in the guard's message. The test or story that forgot the provider then says what to add. When: every provider module you hand to other teams. Verify (RAN,
tips.mjs): the second line below.
tip 1 parent re-renders, value object inline -> Reader renders 1 · value memoised -> Reader renders 0
tip 2 reader outside its provider -> Error: useSeatApi must be used inside <SeatApiProvider>
Source
- react.dev:
useContext, read 2026-10-14 from the page's source. "useContext()call in a component is not affected by providers returned from the same component. The corresponding<Context>needs to be above the component doing theuseContext()call." "This way, if you accidentally render some component without a corresponding provider, it won't break. This also helps your components work well in a test environment without setting up a lot of providers in the tests." "the default value from yourcreateContext(defaultValue)call is only used if there is no matching provider above at all." Steps 3, 5, 6; P1, P3, D1. Valid as of 2026-10-14. - react.dev blog: React 19, "
<Context>as a provider", read 2026-10-14. "In React 19, you can render<Context>as a provider instead of<Context.Provider>" and "New Context providers can use<Context>and we will be publishing a codemod to convert existing providers. In future versions we will deprecate<Context.Provider>." Valid as of 2026-10-14.
Related lessons
Checked against the tools and docs as of 2026-10-14.