What you'll learn: By the end of this lesson you will be able to design the props of a reusable React component so that it stays small as requirements grow: use
childrenand named slots as holes a parent fills, build a compound component that shares state through context, decide when a render prop fits, let a component work both controlled and uncontrolled, and see why reading your ownchildrenbreaks when someone extracts a piece.
Think of a power strip. It does not know what you will plug in: a lamp, a charger, a kettle. It offers sockets and one rule (the same plug fits every socket). A strip with a built-in "lamp mode", "charger mode" and "kettle mode" would need a new model for every new appliance.
A React component can go either way. Props that list every variation grow by one prop per request. Well-placed holes keep the component small, and callers fill them. This lesson is about the second kind, and about the places where the first is still right.
The running example is a product page in a retail storefront: a product card, a tabbed area (Details, Reviews, Shipping) and a list of related products. Six teams will reuse these components, so the shape of their props is a decision that is hard to reverse.
The core idea
Three statements carry the lesson.
- A component API is a contract with every caller. Adding an optional prop is cheap for the author and costly for every reader, and removing one breaks every caller.
- Composition moves decisions to the caller. A component with a
childrenprop has what the React docs call a "hole" that a parent fills with arbitrary JSX. The component decides where things go; the parent decides what they are. - State has one owner, and the API says who. React's docs call a component driven by props "controlled" and one with its own local state "uncontrolled", and note these are not strict terms: most components mix both. A good API lets a caller choose, and never lets an instance quietly switch.
The sources are react.dev pages and the lab in ## Build it. At the time of writing React is at 19.3 (the lab uses 19.3.1). Design opinions are labelled as opinions.
Props that describe versus holes that accept
Illustration 1 (minimal): the same card, two designs. Here is a card built the "describe everything" way. Each thing it can show is a prop:
export interface SprawlCardProps {
readonly title: string;
readonly badge?: string;
readonly body: string;
readonly actionLabel?: string;
readonly onAction?: () => void;
}
It works until the first request that does not fit: a reviews link in the footer (footerLink), an icon in the title (titleIcon), a button that is a link on some pages (actionHref, and now actionLabel and onAction need rules for when each applies). After a year there are a dozen optional props and a table of illegal combinations that exists only in people's heads.
The alternative is a component that owns layout and nothing else:
export interface CardProps {
readonly header?: ReactNode;
readonly footer?: ReactNode;
readonly children: ReactNode;
}
export function Card({ header, footer, children }: CardProps) {
return (
<section>
{header !== undefined && <div data-slot="header">{header}</div>}
<div data-slot="body">{children}</div>
{footer !== undefined && <div data-slot="footer">{footer}</div>}
</section>
);
}
A slot here is a prop whose type is ReactNode: a named hole. children is the unnamed one. The React docs say the pattern is often used for "visual wrappers: panels, grids, etc.". The reviews link becomes <Card footer={<a href="/reviews">Read 12 reviews</a>}>, with no change to Card; the lab has a test for exactly that.
Figure 1. A parent fills the holes of a layout component
How to read this: the left side decides what appears; Card on the right only decides where. Adding a new kind of content changes the left side, never the card.
Composition is not free. The caller writes more JSX, and nothing stops a caller from putting something odd in the header. A component that must enforce structure (every card has exactly one primary action) is better served by narrower props.
Composition before context
The React docs give an order for sharing data: start with props; if data passes through layers that do not use it, "this often means that you forgot to extract some components along the way", so pass JSX as children (<Layout><Posts posts={posts} /></Layout> instead of <Layout posts={posts} />); only then consider context. Composition is the first answer to "prop drilling".
A side effect, observed in the lab and not promised by React's documentation: a wrapper with its own state does not re-render the children it was given when only its state changes, because its parent did not re-render and the element object is the same. A wrapper that creates the child itself re-renders it each time (in the lab, 1 render against 3 after two clicks). Test it before relying on it; Memoization and Re-render Control covers the rest.
Compound components
A compound component is a set of components designed to be used together, sharing state that the caller never sees. The HTML analogy is <select> and <option>: neither makes sense alone.
Illustration 2 (realistic): the product page's tabs. The caller writes this:
<Tabs defaultValue="details">
<Tabs.List label="Product information">
<Tabs.Tab value="details">Details</Tabs.Tab>
<Tabs.Tab value="reviews">Reviews</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="details">Brass base, linen shade.</Tabs.Panel>
<Tabs.Panel value="reviews">12 reviews.</Tabs.Panel>
</Tabs>
The caller controls order, wrapping elements and which panels exist. Tabs holds the selected value and shares it through context. Here is the core of the implementation from the lab:
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs(part: string): TabsContextValue {
const context = useContext(TabsContext);
if (context === null) throw new Error(`<${part}> must be rendered inside <Tabs>`);
return context;
}
function TabsRoot({ value, defaultValue = "", onValueChange, children }: TabsProps) {
const [current, setCurrent] = useControllableState<string>({ value, defaultValue, onChange: onValueChange });
const baseId = useId();
const context = useMemo(() => ({ value: current, select: setCurrent, baseId }), [current, setCurrent, baseId]);
return <TabsContext value={context}>{children}</TabsContext>;
}
Three details are worth copying. The context default is null and the hook throws a message naming the part and its parent, so misuse fails loudly. The provider's value is memoised, because every reader re-renders when it changes (see React Context and Render Cost). And <TabsContext value={...}> is React 19 syntax: "starting in React 19, you can render <SomeContext> as a provider"; older versions use <SomeContext.Provider>.
Figure 2. A click travels through context to the panel
How to read this: Tabs.Tab and Tabs.Panel never talk to each other. Both read the same context, and the root is the single owner of the selected value.
The tradeoff is hidden coupling: Tabs.Tab only works inside Tabs, which the types cannot express, so the thrown error compensates. The lab's tabs have roles, aria-selected and id links between tab and panel, but no arrow-key handling: a teaching sketch, not an accessible tabs widget. The keyboard behaviour a real one needs is not described here because this lesson did not verify it.
Render props
A render prop is, in the React docs' words, "a regular prop which happens to be a function" that tells a component how to render part of its UI. Use one when the component owns something the caller needs in order to render: a loop, a measurement, a piece of state.
export function ItemList<T>({ items, getKey, renderItem, empty }: ItemListProps<T>) {
if (items.length === 0) return <>{empty ?? null}</>;
return (
<ul>
{items.map((item, index) => (
<li key={getKey(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
ItemList owns the loop, the <ul> and the keys (see Keys, Lists and Identity); the caller owns how one product looks; the empty slot replaces the list when there is nothing to show. One component, two tools: a render prop and a slot.
Controlled and uncontrolled
React's input reference draws the line: <input defaultValue="Initial text" /> is uncontrolled, because JSX specifies only the initial value; passing value makes it controlled, and then you must pass an onChange. An input cannot switch between the two over its lifetime; the fix for the common accident of starting with undefined is value={someValue ?? ''}.
Your own components face the same choice. React's "Sharing State Between Components" calls uncontrolled components "easier to use within their parents because they require less configuration" but "less flexible when you want to coordinate them together", and controlled ones "maximally flexible" but needing full configuration by the parent. One small hook can serve both, here from the lab:
export function useControllableState<T>({ value, defaultValue, onChange }: ControllableOptions<T>) {
const [inner, setInner] = useState<T>(defaultValue);
const controlled = value !== undefined;
// ...a ref and an effect report a switch between modes with console.error...
const set = useCallback(
(next: T) => {
if (!controlled) setInner(next);
onChange?.(next);
},
[controlled, onChange],
);
return [controlled ? value : inner, set] as const;
}
If the parent passes value, the hook shows it and only asks the parent to change it; if not, it keeps the state and still reports every change. The lab's tests show the controlled case ignoring a click until the parent updates, and a parent that refuses a change.
Figure 3. Who owns the value
How to read this: the only difference is who stores the value. In the controlled flow the component can ask but the parent has the last word.
Reading the children from inside
Illustration 3 (failure): a list that wraps what it can see. It looks tidy to let a list wrap each child for you:
export function FragileList({ children }: { readonly children: ReactNode }) {
return <ul>{Children.map(children, (child) => <li>{child}</li>)}</ul>;
}
It works until someone extracts two rows into a component. React's Children reference warns that "using Children is uncommon and can lead to fragile code": children is opaque and "does not include rendered output of the components you pass as JSX", so a MoreRows component that renders two rows is "a single child". In the lab, <Row/> plus <MoreRows/> through FragileList gives 2 list items for 3 rows; through ExplicitList and ExplicitRow, where each use states what is a row, it gives 3.
The docs' alternatives are an exposed wrapper component (ExplicitRow), an array of objects as a prop, or a render prop. The lowercase children prop is fine: it "is good and encouraged".
Choosing a tool
Figure 4. Which tool for which API
caller need to supply?"} -->|"content only"| S["children or named slots"] Q1 -->|"several parts that
share state"| C["Compound component
(context)"] Q1 -->|"how to draw data
the component owns"| R["Render prop"] Q1 -->|"a fixed structure
with few variants"| P["Plain props"] C --> Q2{"Should the parent
own the state?"} Q2 -->|"sometimes"| U["Controlled and uncontrolled"] Q2 -->|"never"| K["Keep state inside"]
How to read this: start with what the caller must supply. Most designs end at slots; reach for context and render props only when the component owns something the caller needs.
| Tool | Good for | Watch out for |
|---|---|---|
| Plain props | Fixed structure, few variants | Booleans that combine into illegal states |
children and slots |
Layout and wrappers | Callers can put anything anywhere |
| Compound component | Parts that share state (tabs, menus) | Misuse outside the parent; context re-renders |
| Render prop | A loop or data the component owns | Inline functions defeat memoisation |
| Controlled and uncontrolled | Shared inputs and widgets | Switching modes during a component's life |
Children API, cloneElement |
Rarely | Fragile against extraction |
React's docs also say to spread props (<Avatar {...props} />) with restraint: "if you're using it in every other component, something is wrong".
Build it: the composition kit under jsdom
Goal. See the same card built both ways, and run slots, compound parts, a render prop and a controlled/uncontrolled hook against tests that assert roles, text and render counts.
Setup. Node.js and the examples workspace's toolchain. The tests run under jsdom, which is not a browser.
cd examples/frontend/labs/react-component-composition
../../node_modules/.bin/vitest run # 17 tests
../../node_modules/.bin/tsc -p tsconfig.json
Steps.
- Read
SprawlCardandCardinsrc/components.tsx. Add a new requirement (an icon next to the title) to each and count what you must change. - Run the tests and read the
Tabstest names: uncontrolled, controlled, a parent that redirects a change, a switch reported once, a part outside its parent throwing. - Predict how often
Expensiverenders after two clicks in each wrapper (the tests say 1 and 3), and theFragileListitem count forMoreRows.
There is no demo script or golden file (Node cannot run .tsx directly), so the tests are the evidence. When this lesson was written all 17 passed with React 19.3.1 and the TypeScript check was clean. They say nothing about layout, focus or real-browser behaviour.
Break it. In useControllableState, change return [controlled ? value : inner, set] as const; to return [inner, set] as const;. The controlled Tabs tests fail, because the component now ignores the parent's value. Revert it.
Stretch. Add arrow-key navigation to Tabs.List and test it with user.keyboard.
In practice: one product card reused by six teams
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A retail front-end group owns a shared ProductCard used by six teams (illustrative). The card has fourteen optional props (illustrative), including showBadge, compact, inlineAction, actionHref and actionLabel, and two of them contradict each other in some combinations. Every request becomes a pull request against the shared component, and review is slow because each change risks the other five teams.
Starting architecture
Figure 5. Every variation is a prop on one component
How to read this: all variation lives inside the middle box, so every team's request becomes a change to the same file behind the same review.
Options considered
| Option | What it fixes | What it costs |
|---|---|---|
| Keep adding props | Nothing structural | Review time, illegal combinations |
Split into CompactCard, PartnerCard |
Fewer branches in each | Duplicate layout code and drift |
Card with slots plus small content components |
Callers decide content, layout stays shared | Callers write more JSX; a migration |
The decision
| Field | Decision record |
|---|---|
| Context | One card with many optional props blocks six teams on one review and allows illegal prop combinations. |
| Decision | A layout-only Card with header, footer and children, plus narrow PriceTag and AddToCartButton components. ProductCard stays as a thin wrapper for the two most common layouts. |
| Consequences, good | Teams change their own content without touching the shared layout; few props. |
| Consequences, bad | Callers write more JSX and may make inconsistent cards. |
| Revisit when | Most callers fill the slots the same way (give it a wrapper), or a slot needs shared state. |
Rollout and verification
- Add the new
Cardbeside the old component. Nothing is removed in the first release. - Migrate one page per team, starting with the most contradictory props, and compare screenshots in each team's visual tests.
- Test the interface (an omitted slot renders no element), and mark the old props deprecated in the types until no caller uses them.
The 2 a.m. failure
After a migration, one product list shows two cards squeezed into a single bullet. The list wraps each child in an <li> with Children.map; a developer extracted two cards into MoreCards, and the list now sees one child where it saw two.
First three checks: (1) count wrapper elements in the rendered DOM against the cards; (2) search the list for Children.map or cloneElement; (3) find what changed on the page most recently. Mitigation: inline the extracted component. Follow-up: replace Children with an explicit ListItem wrapper or an items array with a render prop, and test a grouped child, as the lab does.
Architect's notes
- Start narrow, loosen when a second real use arrives. An API with five props is easy to widen and hard to narrow, because removing a prop breaks callers.
- Decide the state owner early, and support both modes only where callers coordinate it. Two modes double the test surface.
- Prefer explicit wrapper components over reading
children(a design opinion that follows the React docs). - Make misuse loud, and write the interface as tests.
Common mistakes
- One boolean prop per variation. Booleans multiply into combinations nobody tests; use a union (
variant: "compact" | "full") or a slot. - Using context where props or
childrenwould do. The React docs say to try those first. - Reading or cloning
children. It breaks when a piece is extracted. - An un-memoised context value, or inline render-prop functions passed to memoised children. Both cause re-renders you did not intend.
Key takeaways
- A component's props are a contract; keep them few, typed and hard to combine illegally.
childrenandReactNodeprops are holes the caller fills; the component owns layout.- Pass JSX as
childrenbefore reaching for context to avoid prop drilling. - A compound component shares hidden state through context; make misuse throw and memoise the context value.
- A render prop is a function prop; use it when the component owns data the caller must draw.
- Controlled means the parent owns the value; uncontrolled means the component does. Do not switch.
- Reading
childrenwith theChildrenAPI breaks when someone extracts a piece.
Practice exercises
Exercise 1 (Starter, 30 min): Take a component with five or more boolean or optional props. List the combinations that make no sense, and redraw its API as a union plus one slot.
Exercise 2 (Intermediate, 2 hours): In the lab, add a header slot to ItemList with a test that it renders nothing when omitted, then render two Tabs on one page and show they do not interfere.
Exercise 3 (Advanced, half day): Write a controlled/uncontrolled Disclosure with open, defaultOpen and onOpenChange, and test that a parent can refuse to open it.
Related reading
In this course:
- React Context and Render Cost, what a provider's value does to every reader.
- Memoization and Re-render Control, when passing
childrenavoids re-renders and whenmemois needed. - Keys, Lists and Identity, why a render-prop list needs stable keys.
- Purity, Strict Mode and Refs, what a render function may and may not do.
Official documentation
- React: Passing Props to a Component,
childrenas a hole. - React: Sharing State Between Components, controlled and uncontrolled.
- React: Passing Data Deeply with Context, props, then
children, then context. - React: createContext, the React 19 provider syntax.
- React: Children, why
Childrenis fragile, and the alternatives. - React:
<input>, controlled and uncontrolled inputs.