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.

Children, function children and render props in React

Turtlekit Editorial Team

The overdue invoices panel let people tick a few invoices and remind all of them at once. When the customers page needed the same ticking, to merge duplicate records, the quickest route was the list that already had checkboxes. Its rows gained a kind, its markup a second branch, and its props a second optional callback.

A month later the team page wanted to remove several members in one go. That would have been a third kind, a third branch and a third callback, in a shared component that two other screens depended on.

Children and render props are how a component hands that kind of decision back to the screen using it. Children pass finished elements in. A render prop, or a function passed as children, is a function the component calls with something only it knows, here which rows are selected.

A shared list every screen had to edit

Here are the list’s props after the customers page arrived:

type Row =
  | { kind: "invoice"; invoice: Invoice }
  | { kind: "customer"; customer: Customer };

interface SelectableListProps {
  rows: readonly Row[];
  onRemind?: (ids: string[]) => void;
  onMerge?: (ids: string[]) => void;
}

And the markup, where each screen has its own branch and its own button:

return (
  <div>
    {selected.length > 0 && onRemind && (
      <button type="button" onClick={() => onRemind(selected)}>
        Send reminders
      </button>
    )}
    {selected.length > 0 && onMerge && (
      <button type="button" onClick={() => onMerge(selected)}>
        Merge customers
      </button>
    )}
    <ul>
      {rows.map((row) => (
        <li key={idOf(row)}>
          <input
            type="checkbox"
            aria-label={`Select ${idOf(row)}`}
            checked={selected.includes(idOf(row))}
            onChange={() => toggle(idOf(row))}
          />
          {row.kind === "invoice" ? (
            <span>
              {row.invoice.id} · {row.invoice.customer} ·{" "}
              {euros.format(row.invoice.amount)}
            </span>
          ) : (
            <span>
              {row.customer.name} · {row.customer.email}
            </span>
          )}
        </li>
      ))}
    </ul>
  </div>
);

Every new screen edits the shared file, and every edit is a chance to break the screens already using it. The label is the quiet casualty. idOf made sense for invoices, so a screen reader heard “Select INV-0048”. For customers it read “Select c2”, because the only name the list could reach was the database id.

Elements for what the page knows, functions for what the list knows

Some of what a list renders is the page’s business: what an invoice row reads, which bulk action exists. Some is the list’s: the checkboxes, select all, the count. The page can hand over its part in two ways. When the content doesn’t depend on the list’s state, it passes elements. When it does, it passes a function, and the list calls it.

Select allbulkActions(ids)renderItem(invoice)renderItem(invoice)renderItem(invoice)
The filled parts belong to the list: the checkboxes and select all. The rest are functions the page passes in, called with what only the list knows.

Nothing about a render prop is special to React: react.dev describes it as a regular prop that happens to be a function.

Handing the content back to the page

The rebuilt list takes four steps, from the frame inward.

Children for the frame

The dashboard’s panels share a frame: a titled section with room for actions. What goes inside has nothing to do with the frame, so it arrives as children:

// A titled section of the dashboard. It owns the frame; what goes
// inside is whatever the page passes as children.
import { useId } from "react";
import type { ReactNode } from "react";

interface PanelProps {
  title: string;
  actions?: ReactNode;
  children: ReactNode;
}

export function Panel({ title, actions, children }: PanelProps) {
  const titleId = useId();
  return (
    <section className="panel" aria-labelledby={titleId}>
      <header className="panel__header">
        <h2 id={titleId}>{title}</h2>
        {actions}
      </header>
      {children}
    </section>
  );
}

The panel never learns about invoices. Anything React can render goes inside it: a list, a chart, an empty state, or a component that the page passes already configured.

The selection as a hook

Before any rendering question comes the behaviour. Selection becomes a hook that takes the keys on screen and derives what is selected from them:

"use client";
// Which rows are selected, derived against the rows on screen: no
// action reaches a hidden row, and a hidden choice returns with its
// row.
import { useState } from "react";

export interface Selection {
  keys: readonly string[];
  all: boolean;
  some: boolean;
  isSelected: (key: string) => boolean;
  toggle: (key: string) => void;
  toggleAll: () => void;
}

export function useSelection(onScreen: readonly string[]): Selection {
  const [chosen, setChosen] = useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const keys = onScreen.filter((key) => chosen.has(key));
  const all = onScreen.length > 0 && keys.length === onScreen.length;

  return {
    keys,
    all,
    some: keys.length > 0 && !all,
    isSelected: (key) => keys.includes(key),
    toggle: (key) =>
      setChosen((current) => {
        const next = new Set(current);
        if (!next.delete(key)) next.add(key);
        return next;
      }),
    toggleAll: () =>
      setChosen((current) => {
        const next = new Set(current);
        for (const key of onScreen) {
          if (all) next.delete(key);
          else next.add(key);
        }
        return next;
      }),
  };
}

The hook stores what people chose and reports only the chosen keys still on screen. Filter the list so INV-0047 drops out, and it drops out of the selection on the same render, so “Send 2 reminders” can never reach an invoice nobody can see. Clear the filter and the choice returns with its row, as people expect from a filter.

Function children are the hook inside a component

Before hooks, a common way to share this behaviour was to call children as a function. The whole component is one line:

"use client";
// Function children: the component calls its children with the
// selection and renders whatever they return. It is the hook, wrapped
// in a component.
import type { ReactNode } from "react";
import { useSelection } from "./use-selection";
import type { Selection as SelectionState } from "./use-selection";

interface SelectionProps<Item> {
  items: readonly Item[];
  getKey: (item: Item) => string;
  children: (selection: SelectionState) => ReactNode;
}

export function Selection<Item>({
  items,
  getKey,
  children,
}: SelectionProps<Item>) {
  return children(useSelection(items.map(getKey)));
}

export function SelectedCount({
  invoices,
}: {
  invoices: readonly { id: string }[];
}) {
  return (
    <Selection items={invoices} getKey={(invoice) => invoice.id}>
      {(selection) => (
        <p role="status">{selection.keys.length} selected</p>
      )}
    </Selection>
  );
}

It works, but nothing at the call site says that children is a function, and the component adds nothing the hook doesn’t already give. Function children earn their place when the component also renders something around the function’s result, such as a provider or a boundary.

Render props, named for the slots they fill

The selectable list does render around the page’s content: a checkbox before every row and a bar above them. So it takes named functions, one per slot:

interface SelectableListProps<Item> {
  /** Names the list: "Overdue invoices". */
  label: string;
  items: readonly Item[];
  getKey: (item: Item) => string;
  /**
   * Names each row's checkbox: "INV-0048" makes "Select INV-0048".
   */
  getLabel: (item: Item) => string;
  renderItem: (item: Item) => ReactNode;
  bulkActions: (keys: readonly string[]) => ReactNode;
}

getLabel sits beside renderItem because the checkbox’s name is text, while a row’s content can be anything. Each row is drawn by the list, with the page’s content placed inside:

return (
  <div className="selectable-list">
    <div className="selectable-list__bar">
      <label>
        <input
          type="checkbox"
          checked={selection.all}
          ref={(box) => {
            if (box) box.indeterminate = selection.some;
          }}
          onChange={selection.toggleAll}
        />
        Select all
      </label>
      <p role="status">{count > 0 ? `${count} selected` : ""}</p>
      {count > 0 ? bulkActions(selection.keys) : null}
    </div>
    <ul aria-label={label}>
      {items.map((item) => (
        <li key={getKey(item)}>
          <input
            type="checkbox"
            aria-label={`Select ${getLabel(item)}`}
            checked={selection.isSelected(getKey(item))}
            onChange={() => selection.toggle(getKey(item))}
          />
          {renderItem(item)}
        </li>
      ))}
    </ul>
  </div>
);

The list sets each key itself, from getKey, so a page’s renderItem never has to think about keys. Select all’s mixed state is a DOM property with no HTML attribute, which is why a ref callback sets it.

The page fills the slots

The overdue invoices panel, assembled:

"use client";
// The dashboard's overdue invoices: a Panel for the frame, children
// for its content, and render props for what only the list can know.
import { Panel } from "./panel";
import { euros } from "./records";
import type { Invoice } from "./records";
import { SelectableList } from "./selectable-list";

interface OverdueInvoicesProps {
  invoices: readonly Invoice[];
  onRemind: (ids: readonly string[]) => void;
}

export function OverdueInvoices({
  invoices,
  onRemind,
}: OverdueInvoicesProps) {
  return (
    <Panel title="Overdue invoices">
      <SelectableList
        label="Overdue invoices"
        items={invoices}
        getKey={(invoice) => invoice.id}
        getLabel={(invoice) => invoice.id}
        renderItem={(invoice) => (
          <span>
            {invoice.id} · {invoice.customer} ·{" "}
            {euros.format(invoice.amount)}
          </span>
        )}
        bulkActions={(ids) => (
          <button type="button" onClick={() => onRemind(ids)}>
            {ids.length === 1
              ? "Send reminder"
              : `Send ${ids.length} reminders`}
          </button>
        )}
      />
    </Panel>
  );
}

Tick INV-0047 and INV-0048, and the bar says “2 selected”, select all shows a mixed state and the button reads “Send 2 reminders”. The customers page passes its own renderItem, a customer’s name for getLabel, and a Merge button. The shared list didn’t change, and the team page’s request won’t change it either.

What each shape costs

Choosing the shape

The question that decides it is what the content depends on:

Which shape fits
The contentShape
Doesn’t depend on the component’s stateChildren, or an element prop such as actions
Needs the state, and the caller renders everythingA hook
Needs the state, inside structure the component drawsNamed render props
Is several parts the caller arrangesCompound components

Children are cheap to re-render; render props are not

When a wrapper updates its own state, the children it was given were created by its parent, and React knows they don’t need to re-render. A render prop is the opposite. The list calls renderItem during its own render, so ticking one checkbox calls it for every row. For a line of text that costs nothing. For a heavy row, return a component wrapped in memo from renderItem, and the rows whose invoice hasn’t changed are skipped.

The list owns the accessibility

Every checkbox is named after its row, select all reports a mixed state, and the count is spoken from a status region that exists before anything is selected. None of that is in a page’s code, so no page can forget it. With function children, each page would draw its own checkboxes, and each would need to get those details right on its own.

Testing the list’s promise

The test renders the finished panel and checks what the list promises any page. Its checkboxes sit beside the page’s rows, the selected keys reach the page’s action, and the selection follows the rows on screen:

import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { OverdueInvoices } from "./overdue-invoices";
import type { Invoice } from "./records";

const INVOICES: Invoice[] = [
  { id: "INV-0047", customer: "Priya Raman", amount: 1200 },
  { id: "INV-0048", customer: "Tomás Ortega", amount: 640 },
  { id: "INV-0049", customer: "Lena Fischer", amount: 95 },
];

test("the page's rows sit beside the list's own checkboxes", () => {
  render(<OverdueInvoices invoices={INVOICES} onRemind={() => {}} />);
  const list = screen.getByRole("list", { name: "Overdue invoices" });
  const [, row] = within(list).getAllByRole("listitem");
  if (!row) throw new Error("no second row");
  expect(row).toHaveTextContent("INV-0048 · Tomás Ortega · €640.00");
  expect(
    within(row).getByRole("checkbox", { name: "Select INV-0048" }),
  ).not.toBeChecked();
});

test("selected rows reach the page's bulk action", async () => {
  const user = userEvent.setup();
  const onRemind = vi.fn();
  render(<OverdueInvoices invoices={INVOICES} onRemind={onRemind} />);
  await user.click(
    screen.getByRole("checkbox", { name: "Select INV-0047" }),
  );
  await user.click(
    screen.getByRole("checkbox", { name: "Select INV-0048" }),
  );
  expect(screen.getByRole("status")).toHaveTextContent("2 selected");
  expect(
    screen.getByRole("checkbox", { name: "Select all" }),
  ).toBePartiallyChecked();
  await user.click(
    screen.getByRole("button", { name: "Send 2 reminders" }),
  );
  expect(onRemind).toHaveBeenCalledWith(["INV-0047", "INV-0048"]);
});

test("a row that leaves the list leaves the selection", async () => {
  const user = userEvent.setup();
  const { rerender } = render(
    <OverdueInvoices invoices={INVOICES} onRemind={() => {}} />,
  );
  await user.click(
    screen.getByRole("checkbox", { name: "Select all" }),
  );
  rerender(
    <OverdueInvoices
      invoices={INVOICES.slice(1)}
      onRemind={() => {}}
    />,
  );
  expect(screen.getByRole("status")).toHaveTextContent("2 selected");
  expect(
    screen.getByRole("checkbox", { name: "Select all" }),
  ).toBeChecked();
});

All three pass. The last one is the bug the derived selection was written to prevent.

Functions stay on one side of the server boundary

Children cross from Server Components to Client Components, because JSX is serialisable. A Server Component can render the panel and fill it with server-rendered content. A render prop is a function, and functions don’t cross, so the component that passes renderItem has to be a client component. That is why overdue-invoices.tsx opens with "use client".

If the rows should render on the server, take them as data instead: an array of objects, each with a key, a label and its content as an element. react.dev lists that shape, a prop that accepts an array of objects, among the alternatives to manipulating children.

From React 18 to 19, and the Compiler

Nothing in these patterns changed in React 19; what changed earlier was hooks, which took over most render props whose only job was sharing logic. If a slot needs a ref, React 19 passes ref to function components as a prop, where React 18 needs forwardRef. The React Compiler can keep the page’s renderItem stable between renders, but the list still calls it whenever the list renders, so memo on a heavy row keeps its value.

Pitfall

Don’t inspect children with Children.map to find the rows. react.dev calls that uncommon and fragile: it sees only the JSX it was passed, not what those components render, so a row wrapped in a page’s own component disappears from the walk.

When a plain prop or a hook is enough

A panel’s title is text, so it is a string prop. Taking it as children invites a heading inside a heading, and screens that style it each their own way.

When a page wants a component’s state and will draw everything itself, give it the hook. And when a component starts collecting renderHeader, renderEmpty and renderFooter beside renderItem, the page wants to arrange parts rather than fill slots, which is what compound components are for.

How Turtlekit’s DataTable takes its slots

Turtlekit’s DataTable is built on render props. A column’s cell draws a cell from its row, rowActions draws a row’s actions, and bulkActions receives the selected ids. getRowLabel names each checkbox, as getLabel did above.

// The overdue invoices with Turtlekit's DataTable: the table owns the
// checkboxes, the bar and the sorting; the page fills its slots.
import { Badge, Button, DataTable } from "turtlekit";
import type { DataTableColumn } from "turtlekit";
import type { Invoice } from "./records";

const columns: DataTableColumn<Invoice>[] = [
  { key: "id", header: "Invoice", value: (invoice) => invoice.id },
  {
    key: "customer",
    header: "Customer",
    value: (invoice) => invoice.customer,
    sortable: true,
  },
  {
    key: "status",
    header: "Status",
    cell: () => <Badge tone="danger">Overdue</Badge>,
  },
  {
    key: "amount",
    header: "Total",
    value: (invoice) => invoice.amount,
    format: "currency",
  },
];

interface OverdueInvoicesProps {
  invoices: readonly Invoice[];
  onRemind: (ids: string[]) => void;
}

export function OverdueInvoices({
  invoices,
  onRemind,
}: OverdueInvoicesProps) {
  return (
    <DataTable
      caption="Overdue invoices"
      columns={columns}
      rows={invoices}
      getRowId={(invoice) => invoice.id}
      getRowLabel={(invoice) => invoice.id}
      selectable
      bulkActions={(ids) => (
        <Button onClick={() => onRemind(ids)}>Send reminders</Button>
      )}
    />
  );
}

The table draws the checkboxes, select all and the bar that replaces the toolbar while rows are selected. It also sorts, and stacks its rows on a phone, so the page holds only the columns and its action. The same idea appears across the kit: ErrorBoundary’s fallback receives the error and a reset, and AppShell’s sidebar receives whether the navigation is collapsed.

Built on this pattern in Turtlekit: Accordion, Button group, Card, Carousel, Checkbox, Context menu, Data table, Elements area, Error boundary, FAQ, Layout, Media, Popover, Radio group, Resizable, Root, Scroll area, Sidebar, Site footer, Site header, Switch, Table, Toast, Tooltip and Visually hidden.

When a slot misbehaves

Three symptoms come up most with slots.

Every row re-renders when one checkbox changes

That is how a render prop works: the list calls it on every render. Return a memoised row component from renderItem when a row is expensive.

React warns about missing keys inside a render prop

The element that needs the key is the one the list creates for each item. Take a getKey and set the key in the list, so callers never have to.

“Functions are not valid as a React child”

A function reached a place that renders children as elements: either a component that expects elements was given a function, or a function component was passed as Row where <Row /> was meant.

Leave the content to the caller

A shared component is a promise to every screen that uses it, and the promise is easiest to keep when it is small. The selectable list promises checkboxes that are named, a count that is spoken and a selection that never reaches a row nobody can see. It no longer promises to know what an invoice or a customer is. Children and render props are how a component keeps its promises narrow, and the screens that use it are better for being trusted with the rest.

Compound components in React

When the page should arrange several parts freely instead of filling slots.

Presentational and container components in React

When the question is where a component’s data comes from rather than what it renders.

Single responsibility in React components

When a component has collected jobs from several screens and needs splitting.

The variant pattern in React

When what varies between screens is a look from a fixed set, not the content.