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.
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:
| The content | Shape |
|---|---|
| Doesn’t depend on the component’s state | Children, or an element prop such as actions |
| Needs the state, and the caller renders everything | A hook |
| Needs the state, inside structure the component draws | Named render props |
| Is several parts the caller arranges | Compound 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.