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.

Headless components and the provider pattern in React

Turtlekit Editorial Team

The ticket came from a customer in Hamburg. Her invoices in the customer portal were in dollars, though she pays in euros, and “Highest total first” put a $90.00 invoice above one for $1,200.00. In the billing app her account manager uses, the same invoices were in euros and in the right order.

The portal had been built in a hurry from the app’s invoice table. The table’s markup didn’t fit cards on a phone, so someone copied the logic into a new component and changed the markup around it. The copy took the sorting and the money format with it, and from that day on the two drifted apart.

Two patterns keep screens like these honest. A headless component owns behaviour and state, here how invoices sort, and renders no markup of its own, so each screen draws it its own way. A provider puts what is true for a whole part of the tree, here the account’s locale and currency, into context once, so nothing below has to be told again.

A copy of the logic is a copy of its decisions

Here is the top of the portal’s component, the part that came from the table:

// Avoid: copied logic, and a format that knows one country
const usd = (cents: number) =>
  (cents / 100).toLocaleString("en-US", {
    style: "currency",
    currency: "USD",
  });

export function InvoiceCards({
  invoices,
}: {
  invoices: readonly Invoice[];
}) {
  const [highestFirst, setHighestFirst] = useState(false);
  const rows = invoices.map((i) => ({
    ...i,
    total: usd(i.totalCents),
  }));
  if (highestFirst)
    rows.sort((a, b) => b.total.localeCompare(a.total));

Two decisions travelled with the copy. The format is fixed to American English and dollars, which was true of the first customer and of nobody in Hamburg. And the sort compares the formatted totals as text. Read character by character, “$90.00” is greater than “$415.50”, which is greater than “$1,200.00”, so the highest-first button puts the smallest invoice on top.

The app’s table had fixed both of these months earlier, in its own file. That is the real cost of the copy: every later fix lands in one place, and the screens disagree a little more with each release. The tempting cure is one InvoiceList with a layout prop, which moves the disagreement inside a single file full of branches. The behaviour wants to be shared and the markup doesn’t.

Share what is true, and let each screen draw it

Split the component along that line. How invoices sort, and what a sorted column tells a screen reader, becomes a hook that returns data and functions and renders nothing. The locale and the currency become a provider: a component near the top that puts them into context, read by a small hook wherever an amount is written.

BillingFormatProviderlocale · currencyuseMoney()InvoiceTableheader buttonsInvoiceCardsa Sort by selectuseInvoiceSort()rows · setSort · toggle · ariaSort
The provider says what is true for the account and the hook says how invoices sort. Neither renders anything; each screen brings its own markup.

Behaviour travels and markup stays home. The table and the cards keep everything that makes them look different, and share everything that makes them right.

Building the shared sort and the account’s format

The shared parts are two files, use-invoice-sort.ts and billing-format.tsx. Then each screen gets its markup, and the portal puts the provider around its own.

The order comes from the raw values

The invoices arrive with their totals in cents and their dates as ISO strings, never as text for people. The comparisons work on those:

export type SortKey = "customer" | "issued" | "totalCents";

export interface Sort {
  key: SortKey;
  direction: "ascending" | "descending";
}

// The raw values decide the order, never the text a screen writes.
const COMPARE: Record<SortKey, (a: Invoice, b: Invoice) => number> = {
  customer: (a, b) => a.customer.localeCompare(b.customer),
  issued: (a, b) => a.issued.localeCompare(b.issued),
  totalCents: (a, b) => a.totalCents - b.totalCents,
};

A number compares as a number and an ISO date compares correctly as text, so no screen can sort by what it happens to display.

A hook that renders nothing

export function useInvoiceSort(
  invoices: readonly Invoice[],
  initial: Sort,
) {
  const [sort, setSort] = useState(initial);
  const sign = sort.direction === "ascending" ? 1 : -1;
  const rows = [...invoices].sort(
    (a, b) => sign * COMPARE[sort.key](a, b),
  );

  // A column header's press: sort by it, or reverse it if it is
  // sorted.
  function toggle(key: SortKey) {
    setSort((current) =>
      current.key === key
        ? {
            key,
            direction:
              current.direction === "ascending"
                ? "descending"
                : "ascending",
          }
        : {
            key,
            direction:
              key === "customer" ? "ascending" : "descending",
          },
    );
  }

  // The aria-sort a column header carries: only the sorted one has
  // it.
  const ariaSort = (key: SortKey) =>
    sort.key === key ? sort.direction : undefined;

  return { rows, sort, setSort, toggle, ariaSort };
}

The hook returns the sorted rows and two ways to change the order. toggle suits a column header, which flips direction on a second press; setSort suits a control that picks an order outright. ariaSort is behaviour too. Which header carries aria-sort, and with which value, is a rule of sorting, so it lives here, and the table only has to put it where it belongs.

The account’s format, given once

interface ProviderProps {
  locale: string;
  currency: string;
  children: ReactNode;
}

export function BillingFormatProvider(props: ProviderProps) {
  const { locale, currency, children } = props;
  // One formatter and one value per setting, so readers re-render
  // only when the account's locale or currency changes.
  const value = useMemo(() => {
    const style = { style: "currency", currency } as const;
    const format = new Intl.NumberFormat(locale, style);
    return {
      locale,
      currency,
      money: (cents: number) => format.format(cents / 100),
    };
  }, [locale, currency]);
  return (
    <BillingFormatContext value={value}>
      {children}
    </BillingFormatContext>
  );
}

The provider takes the two settings and builds one formatter from them. Its value is memoised, so a reader that would otherwise be skipped, one wrapped in memo or passed in as children, re-renders only when the locale or the currency changes.

A reader that says what is missing

/** The account's money format, from the nearest provider. */
export function useMoney(): BillingFormat["money"] {
  const format = use(BillingFormatContext);
  if (!format)
    throw new Error(
      "useMoney() needs a <BillingFormatProvider> above it.",
    );
  return format.money;
}

Without the guard, an amount rendered outside the provider would fail on a property of null, far from the cause. With it, the error names the provider the component needs.

Pitfall

It is tempting to give the context a default, such as American English and dollars, so the guard can go. Then a screen rendered without the provider shows no error at all. It shows dollars, to a customer in Hamburg, which is the ticket this article started from. Keep the default null and let the missing provider fail loudly.

The app’s table

"use client";
// The app's markup: a table whose sortable headers are buttons.
import { useMoney } from "./billing-format";
import type { Invoice } from "./invoices";
import { useInvoiceSort } from "./use-invoice-sort";
import type { SortKey } from "./use-invoice-sort";

export function InvoiceTable({
  invoices,
}: {
  invoices: readonly Invoice[];
}) {
  const money = useMoney();
  const { rows, toggle, ariaSort } = useInvoiceSort(invoices, {
    key: "issued",
    direction: "descending",
  });
  const header = (key: SortKey, label: string) => (
    <th scope="col" aria-sort={ariaSort(key)}>
      <button type="button" onClick={() => toggle(key)}>
        {label}
      </button>
    </th>
  );
  return (
    <table>
      <caption>Invoices</caption>
      <thead>
        <tr>
          {header("customer", "Customer")}
          {header("issued", "Issued")}
          {header("totalCents", "Total")}
        </tr>
      </thead>
      <tbody>
        {rows.map((invoice) => (
          <tr key={invoice.id}>
            <th scope="row">{invoice.customer}</th>
            <td>{invoice.issued}</td>
            <td>{money(invoice.totalCents)}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Each sortable header is a button inside the th, so it is reachable with Tab and pressed with Enter or Space, and the header carries the hook’s aria-sort. The customer’s name is the row header, so a screen reader moving down the Total column hears whose total it is.

The portal’s cards

The portal sorts the same invoices from a select. Its choices map onto the hook’s sort:

const ORDERS = {
  newest: {
    label: "Newest first",
    key: "issued",
    direction: "descending",
  },
  highest: {
    label: "Highest total first",
    key: "totalCents",
    direction: "descending",
  },
  customer: {
    label: "Customer, A to Z",
    key: "customer",
    direction: "ascending",
  },
} satisfies Record<string, Sort & { label: string }>;

type Order = keyof typeof ORDERS;
const NAMES = Object.keys(ORDERS) as Order[];
const isOrder = (name: string): name is Order => name in ORDERS;

// The select's value, derived from the hook's sort: never a second
// state.
const orderOf = (sort: Sort) =>
  NAMES.find(
    (n) =>
      ORDERS[n].key === sort.key &&
      ORDERS[n].direction === sort.direction,
  );

orderOf reads the select’s value from the hook rather than keeping a second state that could disagree with it. The cards themselves are ordinary markup:

export function InvoiceCards({
  invoices,
}: {
  invoices: readonly Invoice[];
}) {
  const id = useId();
  const money = useMoney();
  const { rows, sort, setSort } = useInvoiceSort(
    invoices,
    ORDERS.newest,
  );
  return (
    <>
      <label htmlFor={id}>Sort by</label>
      <select
        id={id}
        value={orderOf(sort)}
        onChange={(event) => {
          const name = event.target.value;
          if (isOrder(name)) setSort(ORDERS[name]);
        }}
      >
        {NAMES.map((name) => (
          <option key={name} value={name}>
            {ORDERS[name].label}
          </option>
        ))}
      </select>
      <ul>
        {rows.map((invoice) => (
          <li key={invoice.id}>
            <h2>{invoice.customer}</h2>
            <p>
              {invoice.id} · {money(invoice.totalCents)}
            </p>
          </li>
        ))}
      </ul>
    </>
  );
}

The portal, finished

// The customer portal: one provider for the account, the shared
// sorting, and markup of its own.
import { BillingFormatProvider } from "./billing-format";
import { InvoiceCards } from "./invoice-cards";
import type { Invoice } from "./invoices";

interface PortalProps {
  invoices: readonly Invoice[];
  /** The account's settings, as the server sends them. */
  account: { locale: string; currency: string };
}

export function InvoicesPortal({ invoices, account }: PortalProps) {
  return (
    <BillingFormatProvider
      locale={account.locale}
      currency={account.currency}
    >
      <main>
        <h1>Your invoices</h1>
        <InvoiceCards invoices={invoices} />
      </main>
    </BillingFormatProvider>
  );
}

The server sends the account’s settings with the page, and the provider gives them to everything inside. For the customer in Hamburg the portal opens newest first, with “1.200,00 €” in German form. Choose “Highest total first” and Brightline Studio’s 1.200,00 € moves to the top, with Northwind’s 90,00 € last. The app’s table, under its own provider, sorts the same way from its headers, because both screens ask the same hook.

What the screens take on when the markup is theirs

Handing over the markup hands over everything that lives in markup, and that is the trade at the centre of the headless idea.

The hook knows the rule, not where it goes

ariaSort knows which header is sorted. It can’t put the attribute on a th, make the header a button or give the select a label. A screen that leaves those out compiles and looks right. The more a headless part leaves to its screens, the more each screen has to get right on its own. Their quality then depends on who built them.

A provider re-renders everything that reads it

When the value changes, every component that reads the context re-renders, and memo doesn’t stop it. A locale and a currency change about once a session, which is why they suit a provider. State that changes while people type does not belong in this one: give it its own provider, or keep it in the component that owns it.

The order, said aloud

Only the sorted header carries aria-sort, as the sortable table example in the WAI-ARIA Authoring Practices does, so a screen reader names the sorted column and its direction when focus enters it. The select is a native one with a visible label, and the totals are text, written in the account’s own format.

Testing each screen through the same hook

The tests render both screens and read them by role, so they follow what a person sees rather than how the hook is built:

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { BillingFormatProvider } from "./billing-format";
import { InvoiceTable } from "./invoice-table";
import { INVOICES } from "./invoices";
import { InvoicesPortal } from "./portal";

const customers = () =>
  screen
    .getAllByRole("heading", { level: 2 })
    .map((h) => h.textContent);

test("the portal shows euros and sorts by amount", async () => {
  const user = userEvent.setup();
  render(
    <InvoicesPortal
      invoices={INVOICES}
      account={{ locale: "de-DE", currency: "EUR" }}
    />,
  );
  expect(customers()).toEqual([
    "Harbour & Finch",
    "Brightline Studio",
    "Northwind Traders",
  ]);
  await user.selectOptions(
    screen.getByRole("combobox", { name: "Sort by" }),
    "Highest total first",
  );
  expect(customers()).toEqual([
    "Brightline Studio",
    "Harbour & Finch",
    "Northwind Traders",
  ]);
  expect(
    screen.getByText(/INV-1042 · 1\.200,00\s€/),
  ).toBeInTheDocument();
});

test("the table marks its sorted column", async () => {
  const user = userEvent.setup();
  render(
    <BillingFormatProvider locale="en-GB" currency="GBP">
      <InvoiceTable invoices={INVOICES} />
    </BillingFormatProvider>,
  );
  const total = screen.getByRole("columnheader", { name: "Total" });
  expect(total).not.toHaveAttribute("aria-sort");
  expect(
    screen.getByRole("columnheader", { name: "Issued" }),
  ).toHaveAttribute("aria-sort", "descending");
  await user.click(
    within(total).getByRole("button", { name: "Total" }),
  );
  expect(total).toHaveAttribute("aria-sort", "descending");
  expect(
    screen.getByRole("columnheader", { name: "Issued" }),
  ).not.toHaveAttribute("aria-sort");
  const [, first] = screen.getAllByRole("row");
  expect(first).toHaveTextContent(
    "Brightline Studio2026-10-19£1,200.00",
  );
});

test("a reader outside its provider says what is missing", () => {
  vi.spyOn(console, "error").mockImplementation(() => {});
  expect(() => render(<InvoiceTable invoices={INVOICES} />)).toThrow(
    "useMoney() needs a <BillingFormatProvider> above it.",
  );
});

All three pass. The first is the one that would have caught the copy, because it chooses Highest total first and checks the order and the euros together.

Server Components and the provider’s props

billing-format.tsx starts with "use client", because context exists only on the client. A Server Component can still render the provider with server-rendered children inside it. That is why the provider takes a locale and a currency, two strings that can cross from server to client, rather than a ready-made formatting function, which can’t.

React 19 and the Compiler

React 19 renders the context itself as its provider, <BillingFormatContext value>, where React 18 writes <BillingFormatContext.Provider value>. use reads the context like useContext and may also be called inside a condition. The React Compiler memoises the provider’s value for you; in existing code, keep the useMemo until you have tested without it.

When one component and a prop will do

A headless part pays for itself when two screens need the same behaviour in different markup. With one screen, keep the logic in its component, close to the state it uses. If both screens would draw the same markup, share a component with props instead, and let the markup be shared too.

Providers have their own limit. A value two or three components need is clearer passed as a prop, where the dependency is visible at the call. Reach for a provider when a value is true of a whole region of the page, such as an account, a theme or a signed-in user. The components in between then have no business carrying it.

How Turtlekit’s Root and DataTable divide the work

Turtlekit takes the opposite side of the headless trade, on purpose. Its components are closed: they take no className or style, and they bring their own markup. The people building with the kit want a finished table, not the parts of one. What the kit keeps is the provider. Root holds the locale, the currency and the time zone, and every number, amount and date inside it is written in them.

// The portal with Turtlekit: Root is the provider for the account,
// and DataTable brings its own markup, its sorting and both of its
// layouts.
import { DataTable, Root } from "turtlekit";
import type { Invoice } from "./invoices";

const COLUMNS = [
  { key: "customer", header: "Customer", sortable: true },
  {
    key: "issued",
    header: "Issued",
    format: "short",
    sortable: true,
  },
  {
    key: "total",
    header: "Total",
    // The raw amount: the column sorts by it and writes it as money.
    value: (invoice: Invoice) => invoice.totalCents / 100,
    format: "currency",
    sortable: true,
  },
] as const;

interface PortalProps {
  invoices: readonly Invoice[];
  account: { locale: string; currency: string };
}

export function InvoicesPortal({ invoices, account }: PortalProps) {
  return (
    <Root
      context="app"
      locale={account.locale}
      currency={account.currency}
    >
      <DataTable
        caption="Your invoices"
        columns={COLUMNS}
        rows={invoices}
        rowHeader="customer"
        defaultSort={{ key: "issued", direction: "descending" }}
        getRowId={(invoice) => invoice.id}
        getRowLabel={(invoice) =>
          `${invoice.id}, ${invoice.customer}`
        }
      />
    </Root>
  );
}

DataTable sorts by each column’s raw value, never its text, and writes the totals in the account’s currency. Its sortable headers are buttons, the sorted one carries aria-sort, and the new order is announced. On a narrow screen its rows stack into cards and a “Sort by” select takes over from the headers. That is the portal’s second markup, kept in the same component as the first.

Built on this pattern in Turtlekit: Root.

When the screens stop agreeing

useMoney throws although the provider is on the page

The reader is not inside it in the React tree. Look for a second React root, such as a widget mounted with its own createRoot, or a provider placed below the component that reads it. A portal is not the cause: content rendered through one still reads the context of the component that rendered it.

Rows wrapped in memo still re-render whenever the page does

The provider’s value is a new object on each render. Memoise it on the settings it holds, or let the React Compiler do it, and move fast-changing state into a provider of its own.

The table and the cards sort differently

One screen is sorting by what it displays. Keep every comparison in the hook, on raw values, and let the screens only ask for an order.

Decide who should own the markup

Headless or styled is less a question of taste than of who should decide what a screen looks like. The portal and the app are owned by people who care about their markup, so the sort and the format are shared and the markup is not. The customer in Hamburg and her account manager now see the same order. A team with nobody to draw that markup is better served by a component that draws it for them. The account’s settings should still arrive once, from the top.

Custom hooks and hook composition in React

When the shared behaviour is smaller than a component: one effect, one subscription.

The state reducer and prop getters in React

When the screens using a headless component need to change its rules or spread its wiring.

Reading context with use() in React 19

When reading the provider conditionally, or with use() in place of useContext.

Compound components in React

When the parts share state through a parent and the page arranges them.