A reviewer left one comment on the invoice page’s actions: return early for a void invoice, before anything else runs. The change moved a single line to the top of the component, and the lint failed on it. The two context reads below the new guard had become hooks called after an early return.
So the guard went back under the two reads, which a void invoice never uses. It is a small thing, and most components that read context have some version of it: the order of the code is set by the rules of hooks rather than by the decision the component makes.
React 19’s use lets the code follow the decision. It reads a context as useContext does, but it can be called inside a condition, a loop or after an early return, so each branch reads only the context it needs.
A guard that had to wait its turn
Here is the component the reviewer read:
// Before: both contexts read first, whatever the invoice
export function InvoiceActions({ invoice }: { invoice: Invoice }) {
const permissions = useContext(PermissionsContext);
const account = useContext(AccountContext);
if (invoice.status === "void") return null;
if (invoice.status !== "paid")
return <button type="button">Send reminder</button>;
if (!permissions.canRefund)
return <p>Ask an owner to refund this invoice.</p>;
if (!account) throw new Error("AccountContext needs a provider.");
return (
<button type="button">
Refund {money(account, invoice.total)}
</button>
);
}Both contexts are read for every invoice. A void invoice reads them and returns nothing; a reminder reads them and uses neither. Only the refund needs the account’s money format. With useContext the reads have to stay above every return, because a hook must run on every render, in the same order.
Nothing here is slow or broken. The cost is in reading: the top of the component claims two dependencies that most of its branches don’t have, and the guard that decides everything sits in the middle.
Read the context where it is used
A context read is a lookup: React finds the closest provider above the component and returns its value. use makes that lookup at the line that asks, which can be any line that runs during render.
One rule stays: use must be called while a component or a hook renders, never in an event handler or an effect. Inside that render, it goes wherever the decision needs it.
Rewriting the actions with use()
The rewrite starts with the contexts themselves, then moves the guards up and the reads down. The page provides both contexts at the end.
Defaults that fail safe
Each context’s default is what a component sees with no provider above it. Choose it as carefully as any other value:
export interface Permissions {
canSend: boolean;
canRefund: boolean;
}
// Outside a provider nobody may do anything: the default fails safe.
export const NO_PERMISSIONS: Permissions = {
canSend: false,
canRefund: false,
};
export const PermissionsContext =
createContext<Permissions>(NO_PERMISSIONS);
export interface Account {
locale: string;
currency: string;
}
// No safe default for money, so a missing provider is an error.
export const AccountContext = createContext<Account | null>(null);
/** A plain function, not a hook, so it can sit in any branch. */
export function required<T>(value: T | null, name: string): T {
if (value === null)
throw new Error(`${name} needs a provider above this component.`);
return value;
}Permissions default to none, so a component rendered outside the provider, in a test or a new page, offers no action it shouldn’t. Money has no safe default, so the account context starts as null, and required turns a missing provider into an error that names it. Because required isn’t a hook, it can sit in any branch.
Guards first
The two cases that need no context return before anything is read:
if (invoice.status === "void") return null;
if (invoice.status === "open" || invoice.status === "overdue") {
return (
<button
type="button"
onClick={() => onAction("remind", invoice.id)}
>
Send reminder
</button>
);
}Each branch reads what it needs
Below the guards, the permissions are read once, and the account only on the way to a refund:
const permissions = use(PermissionsContext);
if (invoice.status === "draft") {
if (!permissions.canSend)
return <p>Ask an owner or an admin to send this invoice.</p>;
return (
<button
type="button"
onClick={() => onAction("send", invoice.id)}
>
Send invoice
</button>
);
}
if (!permissions.canRefund)
return <p>Ask an owner to refund this invoice.</p>;
const account = required(use(AccountContext), "AccountContext");
return (
<button
type="button"
onClick={() => onAction("refund", invoice.id)}
>
Refund {money(account, invoice.total)}
</button>
);The reads now sit beside the code that uses them. A reader sees at once that only a refund needs the money format, and the comment the reviewer left is the component’s first line.
Providing both in React 19
In React 19 a context is its own provider, so the page renders <AccountContext value={account}> where React 18 needs <AccountContext.Provider>:
"use client";
// The invoice page provides both contexts, with React 19's <Context>
// as its own provider.
import { AccountContext, PermissionsContext } from "./account";
import { InvoiceActions } from "./invoice-actions";
import type { Account, Permissions } from "./account";
import type { Invoice, InvoiceAction } from "./invoice";
interface InvoicePageProps {
invoice: Invoice;
account: Account;
permissions: Permissions;
onAction: (action: InvoiceAction, invoiceId: string) => void;
}
export function InvoicePage({
invoice,
account,
permissions,
onAction,
}: InvoicePageProps) {
return (
<AccountContext value={account}>
<PermissionsContext value={permissions}>
<main>
<h1>Invoice {invoice.id}</h1>
<InvoiceActions invoice={invoice} onAction={onAction} />
</main>
</PermissionsContext>
</AccountContext>
);
}The finished actions
"use client";
// The invoice page's actions. The guards come first, and each branch
// reads the context it needs at the point it needs it.
import { use } from "react";
import {
AccountContext,
PermissionsContext,
money,
required,
} from "./account";
import type { Invoice, InvoiceAction } from "./invoice";
interface InvoiceActionsProps {
invoice: Invoice;
onAction: (action: InvoiceAction, invoiceId: string) => void;
}
export function InvoiceActions({
invoice,
onAction,
}: InvoiceActionsProps) {
if (invoice.status === "void") return null;
if (invoice.status === "open" || invoice.status === "overdue") {
return (
<button
type="button"
onClick={() => onAction("remind", invoice.id)}
>
Send reminder
</button>
);
}
const permissions = use(PermissionsContext);
if (invoice.status === "draft") {
if (!permissions.canSend)
return <p>Ask an owner or an admin to send this invoice.</p>;
return (
<button
type="button"
onClick={() => onAction("send", invoice.id)}
>
Send invoice
</button>
);
}
if (!permissions.canRefund)
return <p>Ask an owner to refund this invoice.</p>;
const account = required(use(AccountContext), "AccountContext");
return (
<button
type="button"
onClick={() => onAction("refund", invoice.id)}
>
Refund {money(account, invoice.total)}
</button>
);
}A void invoice shows nothing; an open or overdue one offers “Send reminder”. A paid invoice offers an owner “Refund €1,200.00”, or “Refund 1.200,00 €” on a German account, and tells anyone else that an owner can refund it.
What use() changes, and what it doesn’t
use changes where a read can go. Almost everything else about context stays as it was.
The same re-renders as useContext
When a provider receives a different value, compared with Object.is, every component that reads it re-renders, and memo doesn’t prevent that. Keep provider values stable: pass values that change only when their contents do, or memoise the object you build. When a context’s value changes often, read Context selectors in React.
The freedom belongs to use, not to its wrappers
A custom hook that calls use is still a hook, and the rules of hooks apply to calling it: usePermissions() after an early return fails the lint just as useContext did. The freedom is in use itself. That is why the account’s guard is required, a plain function that takes a value, rather than a useAccount hook: it can follow the read into any branch.
Two contexts, or one
Permissions and the account could share one context. Keeping them apart lets each branch read only what it uses, and means a change to one, such as a role changing mid-session, re-renders only the components that read it. Split contexts by what changes together, not by what sits together on the screen.
Hidden, or explained
The branches decide what people can’t do as well as what they can. An action that never applies, such as refunding a void invoice, is left out. An action someone would expect and can’t take, such as a member refunding a paid invoice, is explained in words, with who can do it. A disabled button with no reason is the version to avoid: it can’t take focus, so someone moving through the page by keyboard passes it without learning why it is there.
Testing with and without a provider
Render through the page to test with real providers, and render the actions alone to test the defaults:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoiceActions } from "./invoice-actions";
import { InvoicePage } from "./invoice-page";
import type { Account, Permissions } from "./account";
import type { Invoice } from "./invoice";
const PAID: Invoice = {
id: "INV-0046",
status: "paid",
total: 120000,
};
const EUROPE: Account = { locale: "en-GB", currency: "EUR" };
const OWNER: Permissions = { canSend: true, canRefund: true };
const MEMBER: Permissions = { canSend: true, canRefund: false };
test("an owner can refund a paid invoice", async () => {
const onAction = vi.fn();
render(
<InvoicePage
invoice={PAID}
account={EUROPE}
permissions={OWNER}
onAction={onAction}
/>,
);
await userEvent.click(
screen.getByRole("button", { name: "Refund €1,200.00" }),
);
expect(onAction).toHaveBeenCalledWith("refund", "INV-0046");
});
test("the account's locale decides how the amount reads", () => {
const germany: Account = { locale: "de-DE", currency: "EUR" };
render(
<InvoicePage
invoice={PAID}
account={germany}
permissions={OWNER}
onAction={() => {}}
/>,
);
expect(
screen.getByRole("button", { name: /^Refund 1\.200,00\s€$/ }),
).toBeVisible();
});
test("a member is told who can refund; void offers none", () => {
const { rerender } = render(
<InvoicePage
invoice={PAID}
account={EUROPE}
permissions={MEMBER}
onAction={() => {}}
/>,
);
expect(
screen.getByText("Ask an owner to refund this invoice."),
).toBeVisible();
const voided: Invoice = { ...PAID, status: "void" };
rerender(
<InvoicePage
invoice={voided}
account={EUROPE}
permissions={OWNER}
onAction={() => {}}
/>,
);
expect(screen.queryByRole("button")).toBeNull();
});
test("outside a provider the actions fail safe", () => {
const draft: Invoice = { ...PAID, status: "draft" };
render(<InvoiceActions invoice={draft} onAction={() => {}} />);
expect(screen.queryByRole("button")).toBeNull();
expect(
screen.getByText(
"Ask an owner or an admin to send this invoice.",
),
).toBeVisible();
});All four pass. Give the permissions context a permissive default and the last one fails: the draft offers “Send invoice” with no provider at all.
Context stops at the server boundary
Reading context with use isn’t supported in Server Components, and neither is createContext; both live in client modules, which is why account.tsx opens with "use client". A Server Component can load the permissions and the account and pass them as props to the page, which provides them to everything below.
React 18, 19 and the Compiler
use is new in React 19. Code that also has to run on React 18, such as a shared library, keeps useContext, which React 19 still supports in full; within an app on 19 the two live side by side. <Context.Provider> still works in React 19, and the React 19 release notes say it will be deprecated in a future version, with a codemod to convert existing providers. The React Compiler memoises a provider’s value for you, so the object a page builds for its provider no longer changes on every render.
When useContext reads just as well
A component that reads its context on every path gains nothing from use; the read sits at the top either way, and either function reads clearly there. Custom hooks that wrap a context read the same with both.
Context itself is the wrong tool for a value one component owns or two siblings share; those belong in state, held where State colocation in React and Lifting state up in React put it.
How Turtlekit’s Root provides formats to every component
Root is a provider at the public API. Its locale, currency and timeZone reach every component inside it, so Metric and DataTable write money in the account’s format without a prop of their own. Button’s unavailable covers the explained case:
// The paid invoice with Turtlekit: Root provides the account's locale
// and currency, which Metric reads wherever it renders, and Button
// says why an action can't be used.
import type { ReactNode } from "react";
import { Button, Metric, Root, Stack } from "turtlekit";
export function AccountRoot({
locale,
children,
}: {
locale: string;
children: ReactNode;
}) {
return (
<Root context="app" locale={locale} currency="EUR">
{children}
</Root>
);
}
interface PaidInvoiceProps {
total: number;
canRefund: boolean;
onRefund: () => void;
}
export function PaidInvoice({
total,
canRefund,
onRefund,
}: PaidInvoiceProps) {
return (
<Stack gap="items">
<Metric label="Paid" value={total / 100} format="currency" />
<Button
variant="soft"
unavailable={
canRefund
? undefined
: "Only an owner can refund an invoice"
}
onClick={onRefund}
>
Refund
</Button>
</Stack>
);
}Inside an AccountRoot with locale="de-DE", the same Metric reads “1.200,00 €”. The button keeps its label and says why it can’t be used, instead of disappearing.
When a read comes back wrong
Three problems account for most of the trouble with reading context through use.
use isn’t exported from react
The project is on React 18. Upgrade, or keep useContext with the reads at the top.
The component gets the default value
The provider isn’t above the reading component. It may be rendered by the same component, which doesn’t count, or the build may include two copies of the module that creates the context, so the provider and the reader hold different contexts.
use(Context) fails in a Server Component
Context can’t be read on the server side of the boundary. Move the read into a Client Component, or pass the value as a prop.
Code in the order of the decision
use is a small API with a narrow effect: it moves a line. The line matters because a component is read far more often than it is written, and a reader follows it from the top. When the first lines are the guards and the reads sit beside their branches, the component says what it decides in the order it decides it. That is worth one more import, and it is why the reviewer’s comment could finally be taken.