A customer on the free plan wrote to support with a screenshot of the invoice export page. For a moment after it opened, it had offered an Export button, and then the button was gone. The server had refused the export, as it should, but the page had promised something it couldn’t give.
The page was wrapped twice, once to read the workspace’s plan and once to read its invoices. Both wrappers passed the page a prop called loading. The inner one won, so whenever the invoices arrived first, the page stopped waiting before it knew the plan.
Each wrapper was a higher-order component: a function that takes a component and returns a new one, with props or behaviour added around it. Before hooks it was, with render props, one of the main ways to share logic between components. It still has a few places where it is the right tool, which is why its failure modes are worth knowing precisely.
Two wrappers, one prop name
Here are the two higher-order components, as they had been written a year apart:
function withPlan<P extends object>(Component: ComponentType<P>) {
return function WithPlan(props: Omit<P, "plan" | "loading">) {
const plan = use(PlanContext);
return (
<Component
{...(props as P)}
plan={plan}
loading={plan === null}
/>
);
};
}
function withInvoices<P extends object>(Component: ComponentType<P>) {
return function WithInvoices(
props: Omit<P, "invoices" | "loading">,
) {
const invoices = use(InvoicesContext);
return (
<Component
{...(props as P)}
invoices={invoices ?? []}
loading={invoices === null}
/>
);
};
}And the page, which trusts whatever arrives as loading:
interface ExportPageProps {
plan: Plan | null;
invoices: Invoice[];
loading: boolean;
}
function ExportPage({ plan, invoices, loading }: ExportPageProps) {
if (loading) return <p>Loading…</p>;
const canExport = plan?.tier !== "free";
return canExport ? (
<button type="button">Export {invoices.length} invoices</button>
) : null;
}
export default withPlan(withInvoices(ExportPage));Read the last line from the inside out. withPlan renders WithInvoices with the plan’s loading, and WithInvoices spreads it and then sets its own, which overrides it. When the invoices arrived first, loading was false, plan was still null, and plan?.tier !== "free" was true. TypeScript checks each wrapper on its own, and each one’s Omit hides loading from its caller, so the overlap never reaches a type.
Three places to add behaviour
There are three places to attach shared behaviour to a component. A hook adds it inside a component, a wrapper adds it around one, and a higher-order component adds it to the definition, before anyone renders it.
Only the hook is visible where it takes effect. A component that calls usePlan() names its own variable, so two hooks can’t write to the same name. A higher-order component works from outside, and the component receives props that its callers never passed and its own code never asked for.
Gating the Pro pages three ways
The plan itself becomes context, read in one place. Each of the three shapes then uses it for what it does best.
"use client";
// The workspace's plan, read anywhere below the app's provider. It is
// null until the plan has loaded.
import { createContext, use } from "react";
export interface Plan {
tier: "free" | "pro";
}
export const PlanContext = createContext<Plan | null>(null);
export function usePlan(): Plan | null {
return use(PlanContext);
}A hook where a component needs the value
A component that needs the plan reads it directly, and the name it gets is its own:
export function InvoiceFooter() {
const plan = usePlan();
if (plan?.tier !== "pro") return null;
return <p>Exports include every field on the invoice.</p>;
}While the plan is loading, plan is null and the note stays hidden. There is no separate loading to fall out of step with the value.
A wrapper for behaviour around a whole page
Gating is behaviour around a page: show it on Pro, and say what it is everywhere else. That makes it a component that takes the page as children:
"use client";
// A wrapper that shows its children on the Pro plan, and otherwise
// says what the feature is and where to get it.
import { useId } from "react";
import type { ReactNode } from "react";
import { usePlan } from "./plan";
interface PlanGateProps {
/** The feature's name, as people know it: "Invoice export". */
feature: string;
children: ReactNode;
}
export function PlanGate({ feature, children }: PlanGateProps) {
const plan = usePlan();
const titleId = useId();
if (plan === null)
return (
<main>
<p>Checking your plan…</p>
</main>
);
if (plan.tier === "pro") return children;
return (
<main aria-labelledby={titleId}>
<h1 id={titleId}>{feature} is on the Pro plan</h1>
<p>
Upgrade to use it. Everything you have today stays as it is.
</p>
<a href="/settings/plan">Compare plans</a>
</main>
);
}The gate has three outcomes, and the free one does more than hide the page. People on the free plan see the feature’s name, what upgrading would mean and a link to the plans, which is what the customer in the screenshot was missing.
A higher-order component where code needs a component
The router takes a component per path, not an element, so the gate has to be a component type there. That is a real reason to write a higher-order component, and this one is built on the wrapper:
"use client";
// The same gate as a higher-order component, for code that needs a
// component rather than an element. It adds no props, so it can't
// collide.
import type { ComponentType } from "react";
import { PlanGate } from "./plan-gate";
export function withPlanGate<P extends object>(
Component: ComponentType<P>,
feature: string,
) {
function Gated(props: P) {
return (
<PlanGate feature={feature}>
<Component {...props} />
</PlanGate>
);
}
const name = Component.displayName ?? Component.name;
Gated.displayName = `withPlanGate(${name})`;
return Gated;
}It adds no props, so it can’t collide with anything. Its props type is the wrapped component’s own P, so callers see exactly the props they had before. And its displayName carries the wrapped component’s name, so DevTools and error messages show withPlanGate(ExportPage) rather than a column of identical wrappers.
The route table
The gate is applied where the routes are declared, once each:
"use client";
// The router takes a component per path, so the gate is applied here,
// once, at module level: never inside a render, where each call is a
// new type.
import type { ComponentType } from "react";
import { ExportPage, RecurringInvoicesPage } from "./pages";
import { withPlanGate } from "./with-plan-gate";
interface Route {
path: string;
component: ComponentType;
}
export const PRO_ROUTES: readonly Route[] = [
{
path: "/invoices/export",
component: withPlanGate(ExportPage, "Invoice export"),
},
{
path: "/invoices/recurring",
component: withPlanGate(
RecurringInvoicesPage,
"Recurring invoices",
),
},
];Open /invoices/export on the free plan and the page shows “Checking your plan…”, then “Invoice export is on the Pro plan” with a link to compare plans. On Pro it shows the export page as written. The pages themselves never mention plans.
Pitfall
In JSX, a later prop overrides an earlier one, spreads included. <Component {...props} loading={x} /> overrides the caller’s loading, and <Component loading={x} {...props} /> lets the caller override the wrapper. Every higher-order component that injects props picks one of these, and the choice is invisible at the call site.
What a higher-order component asks of its callers
Which shape for which job
The three shapes differ in where the behaviour sits and who can see it:
| Shape | Best for |
|---|---|
| Hook | A value or behaviour a component uses in its own code |
| Wrapper component | Behaviour around rendered output: a gate, a boundary, a provider |
| Higher-order component | Code that needs a component type, such as a route table |
Applied once, at module level
Calling withPlanGate inside a component creates a new component type on every render. React sees a different type in the same place, unmounts the old one and mounts the new, and every bit of state below it is reset. Apply it where the module loads, as the route table does. Each layer is also a component in the tree, so a stack of five adds five components to every render of the page. React’s own memo has the same shape: it takes a component and returns a new one.
A gate that tells people why
A feature that vanishes without a reason reads as a fault. The gate replaces the page with a heading that names the feature, a sentence about what upgrading changes and a real link. Both states keep the page’s main landmark, and the notice is its h1, so a screen reader user lands on a page that says where they are. While the plan loads, the page says “Checking your plan…” instead of rendering nothing.
One test for every gated page
Because every Pro route goes through the same gate, its contract is tested once, through a real route and the real context:
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { PlanContext } from "./plan";
import type { Plan } from "./plan";
import { PRO_ROUTES } from "./pro-routes";
const exportRoute = PRO_ROUTES.find(
(route) => route.path === "/invoices/export",
);
if (!exportRoute) throw new Error("no export route");
const ExportRoute = exportRoute.component;
function renderOn(plan: Plan | null) {
render(
<PlanContext value={plan}>
<ExportRoute />
</PlanContext>,
);
}
test("on Pro, the page renders as written", () => {
renderOn({ tier: "pro" });
expect(
screen.getByRole("heading", {
level: 1,
name: "Export invoices",
}),
).toBeVisible();
});
test("on Free, the page names the feature and how to get it", () => {
renderOn({ tier: "free" });
expect(
screen.queryByRole("button", { name: "Export as CSV" }),
).toBeNull();
expect(
screen.getByRole("heading", {
level: 1,
name: "Invoice export is on the Pro plan",
}),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Compare plans" }),
).toHaveAttribute("href", "/settings/plan");
});
test("until the plan loads, nothing is offered", () => {
renderOn(null);
expect(screen.getByRole("main")).toHaveTextContent(
"Checking your plan…",
);
expect(screen.queryByRole("button")).toBeNull();
});
test("DevTools names the layer after the page it wraps", () => {
expect(ExportRoute.displayName).toBe("withPlanGate(ExportPage)");
});All four pass. The third is the original bug: until the plan is known, no button is offered.
On the server, decide where the data is read
Server Components can’t read context, so the gate is a client component. That costs the wrapper little: <PlanGate> can take a server-rendered page as its children. The higher-order component can’t, because it needs the page’s component type in client code, which makes the page a client component too. A page rendered on the server can also read the plan along with its data and render the page or the notice directly, with no gate at all. Either way, the gate is for people. The export endpoint checks the plan on every request, because a hidden button was never a permission.
React 19 retired the old chores
In React 18 every higher-order component needed forwardRef to pass a ref through. React 19 passes ref to function components as a prop, so the spread in Gated forwards it with everything else; a class component’s ref still isn’t a prop. React 19 also removed defaultProps for function components and legacy context, which older higher-order components often leaned on, so those are the first to check in an upgrade. One old chore remains: static properties such as ExportPage.loader are not copied to the wrapper, and must be copied by hand where something reads them.
When a hook or a wrapper says it better
In new code, start with the hook or the wrapper. If a component needs a value, a hook gives it the value under a name it chose. If the behaviour sits around what a component renders, a wrapper shows that in the JSX, where the next reader will look for it.
Keep higher-order components for the places that genuinely take a component: a route table, a library that asks for component types, or a function that decorates a definition the way memo does. Even there, build them on a wrapper and inject nothing, as withPlanGate does.
How Turtlekit’s ErrorBoundary guards a route
An error boundary is the clearest case for a wrapper, because react.dev notes that there is no way to write one as a function component. Turtlekit’s ErrorBoundary is that class, written once: a component that takes the page as children and, when something below it throws, shows a fallback titled with what failed and a button to try again. It also takes onError for reporting and resetKeys to recover when the page moves on.
// A route table's error handling with Turtlekit's ErrorBoundary. The
// kit writes the class React requires; this function only applies it.
import type { ComponentType } from "react";
import { ErrorBoundary } from "turtlekit";
export function withErrorBoundary<P extends object>(
Page: ComponentType<P>,
title: string,
) {
function Guarded(props: P) {
return (
<ErrorBoundary title={title}>
<Page {...props} />
</ErrorBoundary>
);
}
const name = Page.displayName ?? Page.name;
Guarded.displayName = `withErrorBoundary(${name})`;
return Guarded;
}Applied in the route table next to withPlanGate, it gives every route its own boundary. A failing export page shows “Invoice export could not load” and a Try again button, and the navigation around it keeps working.
When a wrapped component misbehaves
Each of these traces back to something the wrapper does out of sight.
A prop the wrapper provides arrives with the wrong value
Another wrapper, or the caller, passes the same name later in the props. Rename one, or replace the injection with a hook that the component calls itself.
The wrapped component loses its state on every render
The higher-order component is being called during a render, so each render produces a new component type. Move the call to module level.
A ref reaches the wrapper but not the element inside
On React 18, the wrapper needs forwardRef. On React 19, check that the wrapper spreads its props onto the inner component, and that the inner component is a function rather than a class.
Add nothing the caller can’t see
Higher-order components earned a poor reputation in the years when they and render props were the main ways to share logic, and stacks of them injected props that nobody could trace. What made them hard to follow was what they added out of sight. One that only places a component inside a wrapper is as easy to read as the wrapper itself. In the few places that need a component type, it is also the plainest way to say what every page in a table has in common.