The request came in on a Thursday: put the “Look up” button next to the VAT number, not under it. Everyone expected a one-line change. The field component on the billing settings page decided its own layout, though, and the only way to change that layout was another prop.
That component had started small, with a label, an input and an optional error. Then the VAT number needed a link in its hint and a button that fetches the company name from the registry. Each request arrived as a prop.
There is a better shape for components like this one, and it is the one tabs, menus and well-made form fields share: compound components. A parent shares state with its parts through context, so each page arranges the parts while the parent keeps them wired together.
Every prop is a layout decision made in advance
Here is the field’s API after three requests. Count the props that describe placement rather than data:
// Avoid: one prop per layout request
interface FormFieldProps {
label: string;
name: string;
type?: "text" | "email";
hint?: string;
hintLinkHref?: string;
hintLinkText?: string;
error?: string;
actionLabel?: string;
onAction?: () => void;
}The VAT number’s call site reads like a configuration file, and it still can’t put the button beside the input:
<FormField
label="VAT number"
name="vatNumber"
hint="Starts with your country code."
hintLinkHref="/help/vat"
hintLinkText="Where to find it"
error={error}
actionLabel="Look up"
onAction={() => window.alert("Looking up the company")}
/>The cost spreads beyond one file. The author of the shipping form gave up on the component, wrote a raw <input> and forgot aria-describedby, so screen reader users on that form never hear the hint. When a component can’t express a layout, people route around it, and the accessibility wiring is the first thing lost on the way.
Let the parent own the wiring and the page own the layout
Split the field into parts that each render one element: Field.Label, Field.Hint, Field.Input and Field.Error. The parent, Field, holds only what the parts have to agree on: the ids that tie the label and the hint to the input, and whether the value is invalid. It shares them through context.
The page writes the parts like HTML, in any order, with anything in between. The parent never sees the layout, and the layout never touches an id.
Building the field one part at a time
What follows is one file, field.tsx, in four fragments, and then the billing form that uses it.
A context that refuses to be misused
Start with what the parts share. The detail to notice is the guard in useField:
type Part = "hint" | "error";
interface FieldState {
inputId: string;
hintId: string;
errorId: string;
invalid: boolean;
parts: ReadonlySet<Part>;
register: (part: Part) => () => void;
}
const FieldContext = createContext<FieldState | null>(null);
function useField(part: string): FieldState {
const field = use(FieldContext);
if (!field)
throw new Error(`<Field.${part}> must be inside a <Field>.`);
return field;
}Without it, a part rendered outside Field reads the context’s default, null, and fails a few lines later with an error that names neither the part nor the field. Fail on the first read instead, with the part’s name in the message.
Ids that match on the server and in the browser
The root creates its ids with useId, so the server’s HTML and the hydrated page agree, and keeps a registry of the parts on screen:
export function Field(props: {
invalid?: boolean;
children: ReactNode;
}) {
const { invalid = false, children } = props;
const id = useId();
const [parts, setParts] = useState<ReadonlySet<Part>>(
() => new Set(),
);
// Stable, so a part's effect runs once, not on every change to
// `parts`.
const register = useCallback((part: Part) => {
setParts((current) => new Set(current).add(part));
return () =>
setParts(
(current) => new Set([...current].filter((p) => p !== part)),
);
}, []);
// One object per change, not per render: every part re-renders when
// it changes.
const value = useMemo<FieldState>(
() => ({
inputId: `${id}-input`,
hintId: `${id}-hint`,
errorId: `${id}-error`,
invalid,
parts,
register,
}),
[id, invalid, parts, register],
);
return (
<FieldContext value={value}>
<div className="field">{children}</div>
</FieldContext>
);
}register is created once. If it changed every time parts did, each part’s effect would rerun, register again and loop.
Parts that announce themselves
Each part reads only what it needs. The hint and the error register in an effect and leave in its cleanup:
function Label(props: ComponentProps<"label">) {
const { inputId } = useField("Label");
return <label {...props} htmlFor={inputId} />;
}
function Hint(props: ComponentProps<"p">) {
const { hintId, register } = useField("Hint");
useEffect(() => register("hint"), [register]);
return <p {...props} id={hintId} />;
}
function ErrorMessage(props: ComponentProps<"p">) {
const { errorId, invalid, register } = useField("Error");
useEffect(
() => (invalid ? register("error") : undefined),
[invalid, register],
);
return invalid ? <p {...props} id={errorId} /> : null;
}Because every part accepts its native element’s props, the page can add a class, a link or aria-live without the field growing a single prop.
An input that points only at what exists
The input builds its description from the registry rather than from guesses:
function Input(props: ComponentProps<"input">) {
const { inputId, hintId, errorId, invalid, parts } =
useField("Input");
const describedBy = [
parts.has("hint") ? hintId : null,
parts.has("error") ? errorId : null,
].filter(Boolean);
return (
<input
{...props}
id={inputId}
aria-invalid={invalid || undefined}
aria-describedby={describedBy.join(" ") || undefined}
/>
);
}Follow the VAT input through its life, with r1 standing in for the id React generates:
| Moment | parts | aria-describedby |
|---|---|---|
| First render | empty | none |
| After the hint’s effect | hint | r1-hint |
| After an invalid save | hint, error | r1-hint r1-error |
The input lists the hint before the error, the order the parts usually sit in. Remember the empty first render; it returns when the page is rendered on the server.
The form design asked for
The last lines of the file attach the parts to Field and export each one by name:
Field.Label = Label;
Field.Hint = Hint;
Field.Input = Input;
Field.Error = ErrorMessage;
// Named exports too, for code that imports only the parts it renders.
export { Label as FieldLabel, Hint as FieldHint };
export { Input as FieldInput, ErrorMessage as FieldError };And the billing page can finally put the button where design wanted it:
// The billing page's details form, composed from the Field parts.
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { Field } from "./field";
const VAT_NUMBER = /^[A-Z]{2}[0-9A-Z]{8,12}$/;
export function BillingDetails({
onLookUp,
}: {
onLookUp: (vat: string) => void;
}) {
const vatInput = useRef<HTMLInputElement>(null);
const [invalid, setInvalid] = useState(false);
// The inputs keep their own values: typing re-renders nothing above
// them.
function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const vat = String(
new FormData(event.currentTarget).get("vatNumber"),
);
const wrong = !VAT_NUMBER.test(vat.toUpperCase());
setInvalid(wrong);
if (wrong) vatInput.current?.focus();
}
return (
<form onSubmit={save} noValidate>
<Field>
<Field.Label>Billing email</Field.Label>
<Field.Hint>Invoices and receipts go here.</Field.Hint>
<Field.Input
name="billingEmail"
type="email"
autoComplete="email"
/>
</Field>
<Field invalid={invalid}>
<Field.Label>VAT number</Field.Label>
<Field.Hint>
Starts with your country code.{" "}
<a href="/help/vat">Where to find it</a>
</Field.Hint>
<div className="field-row">
<Field.Input
ref={vatInput}
name="vatNumber"
autoComplete="off"
/>
<button
type="button"
onClick={() => onLookUp(vatInput.current?.value ?? "")}
>
Look up
</button>
</div>
<Field.Error>
Enter the number as registered, such as DE123456789.
</Field.Error>
</Field>
<button type="submit">Save billing details</button>
</form>
);
}The inputs keep their own values, so typing re-renders nothing above them, and the button reads the VAT number through the ref that Field.Input passes on. Save with “DE12” and focus moves to the VAT input, now marked invalid with the error under the row. A screen reader reads the label, the hint, then the error. Thursday’s request has become a change to the page.
Deep dive
Why do the parts register themselves instead of the parent reading children? Children.map sees only the JSX it was handed, not what those components render. Wrap the VAT input in a div, or render the hint from a page’s own <VatHint>, and the part drops out of the walk. A part that registers works wherever it ends up.
The price of handing over the layout
Parts are not free, and most of what they cost shows up in places the happy path never visits.
Freedom has an API price
Parts buy layout freedom and cost API surface. There are more exports to learn, documentation of which parts exist, and rules no type can enforce, such as one input per field. Props keep the API closed, at the price of a release for every new layout. The deciding question is what actually varies:
| What varies | Shape |
|---|---|
| Nothing: one layout everywhere | Props |
| The control and what sits beside it | Props for the fixed parts, a child for the control |
| The order and the markup between parts | Compound parts |
Keep what changes on every keystroke out of the context
When a provider’s value changes, every component reading it re-renders, and memo doesn’t stop that. The root memoises its value so the parts re-render only when an id, the invalid flag or the registry changes, and the billing form leaves the typed values in the inputs. Put the value in the context instead and every keystroke re-renders every part of the field; lift it to the form’s state and it re-renders the whole form.
The wiring is the accessibility
Most of what Field does is accessibility work: htmlFor on the label, aria-describedby for the hint and the error, aria-invalid on the input. The error text matters as much as the wiring. Say how to fix the value, as WCAG’s error suggestion criterion asks, and on save move focus to the first invalid field or to a summary of the errors.
Tests that read the page like a screen reader
The useful test asserts what the parts promise together: the input is named by its label, described by its hint and error, and marked invalid. Role queries read names and descriptions the way assistive technology does, so these tests break when the wiring breaks and shrug when the markup changes:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { BillingDetails } from "./billing-details";
import { Field } from "./field";
test("each input is named and described by its own parts", () => {
render(<BillingDetails onLookUp={() => {}} />);
const email = screen.getByRole("textbox", {
name: "Billing email",
});
expect(email).toHaveAccessibleDescription(
"Invoices and receipts go here.",
);
expect(email).not.toBeInvalid();
});
test("a bad VAT number is marked and described", async () => {
const user = userEvent.setup();
render(<BillingDetails onLookUp={() => {}} />);
const vat = screen.getByRole("textbox", { name: "VAT number" });
await user.type(vat, "de12");
await user.click(
screen.getByRole("button", { name: "Save billing details" }),
);
expect(vat).toBeInvalid();
expect(vat).toHaveFocus();
expect(vat).toHaveAccessibleDescription(
/^Starts with your country code\..*as registered/,
);
});
test("a part outside its Field says what is wrong", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<Field.Hint>Orphan</Field.Hint>)).toThrow(
"<Field.Hint> must be inside a <Field>.",
);
});All three pass. Move a hint outside its field and the third test tells you why.
What the server sends first
field.tsx opens with "use client" because it holds state and context. A Server Component can still render Field and pass the parts as children, since JSX crosses the boundary. That is also why the parts are exported by name: server code imports exactly what it renders instead of reaching into a property of a client module.
Registration runs in effects, and effects don’t run on the server. The first HTML therefore arrives without aria-describedby, which appears with hydration. When the description has to be in the first HTML, pass the hint as a prop, which is the hybrid row in the table above.
React 19 drops two wrappers
React 19 renders the context itself as its provider, <FieldContext value={value}>, where React 18 needs <FieldContext.Provider value={value}>. use(FieldContext) reads it like useContext, and use may also run inside a condition. Field.Input takes a ref without forwardRef, because React 19 passes ref to function components as a prop and the spread hands it to the input.
The React Compiler memoises the context value for you. In new code, rely on it and drop the useMemo; in existing code, leave the manual memo in place until you have tested without it, as the Compiler’s guidance advises.
When props are still the better shape
A component with one layout everywhere gains nothing from parts. A settings row with a label, a description and a switch never changes order, and splitting it would only add exports to learn.
The field built here lets a page move every part, and some teams won’t want that much freedom. Label, hint, control and error is the order a screen reader needs, and a page that reorders it can end up with a field that reads its error first. If only the control and what sits beside it ever vary, take the label, the hint and the error as props and keep one children slot for the rest. That hybrid is where this field ends up in Turtlekit.
How Turtlekit’s Field and Tabs are built
Turtlekit’s Field takes the hybrid shape. Its label, hint and error are props, so their order and wiring are fixed and present in the first HTML. Its control is the child: TextInput, SelectControl or a switch reads its id and descriptions from the field’s context. Tabs is fully compound, with TabList, Tab and TabPanel sharing the selected value and each panel holding anything.
// The same billing settings with Turtlekit's Field and Tabs: the
// label, hint and error as props, and the input with its button in
// the child slot.
import {
Button,
Field,
Inline,
Tab,
TabList,
TabPanel,
Tabs,
TextInput,
} from "turtlekit";
interface BillingSettingsProps {
vatError?: string;
onLookUp: () => void;
}
export function BillingSettings({
vatError,
onLookUp,
}: BillingSettingsProps) {
return (
<Tabs defaultValue="details">
<TabList aria-label="Billing settings">
<Tab value="details">Details</Tab>
<Tab value="invoices">Invoices</Tab>
</TabList>
<TabPanel value="details">
<Field
label="VAT number"
hint="Starts with your country code."
error={vatError}
>
<Inline gap="tight" wrap={false}>
<TextInput name="vatNumber" autoComplete="off" />
<Button variant="soft" onClick={onLookUp}>
Look up
</Button>
</Inline>
</Field>
</TabPanel>
<TabPanel value="invoices">{/* The invoice table */}</TabPanel>
</Tabs>
);
}The “Look up” button sits beside the input in the child slot, which is all Thursday’s request needed. The tabs move with the arrow keys, Home and End, mirrored in right-to-left text, with nothing wired by hand.
Built on this pattern in Turtlekit: Field, Item, One-time code, Select, Slider, Tabs, Text area, Text field and Toolbar.
Three ways the wiring breaks
Three symptoms account for most of the trouble with a field like this one.
The hint isn’t read when the input is focused
Check that the hint renders inside the same Field as the input. If it does, look at the live page rather than the server HTML, because the description arrives with hydration.
Every field re-renders while someone types
Something above the fields holds the typed value: a provider wrapping the whole form, or the form’s own state. Give each field its own provider and let the inputs keep their values until save.
A Server Component can’t render Field.Label
Server code sees a client module only through its exports, and React stops it from reaching into a property such as Field.Label. Import FieldLabel and the other named parts instead.
Decide less, wire more
The instinct with a shared component is to make it cleverer, so every new request becomes one more thing it knows. The field that lasts knows less. It holds the few facts its parts must agree on, gets the accessibility right every time, and leaves the arrangement to the page that can see the design. Most of the work in a good compound component goes into deciding what the parent should never decide.