Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Higher-order components in React

Turtlekit Editorial Team

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.

withPlanGate(Page)definition<PlanGate>treePagebodyusePlan()
A higher-order component changes the definition, a wrapper the tree around the component, a hook the body. Only the hook’s addition can be read in the component’s own code.

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:

A hook, a wrapper or a higher-order component
ShapeBest for
HookA value or behaviour a component uses in its own code
Wrapper componentBehaviour around rendered output: a gate, a boundary, a provider
Higher-order componentCode 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.

Children, function children and render props in React

When the behaviour should sit around content the caller passes in.

Presentational and container components in React

When the wrapper’s real job is to fetch data for a component that only draws it.

Single responsibility in React components

When a component has gathered wrappers because it does too many jobs.

Compound components in React

When several parts need shared state, rather than one component an extra prop.