Skip to content

    Frontend System Design

    Component Composition and API Design

    Design React component APIs with children, slots, compound components, render props and controlled or uncontrolled state, and know which tool fits which job.

    By Published 12 min read

    • frontend-system-design
    • react
    • components
    • composition
    • api-design
    Share

    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 children and 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 own children breaks 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.

    1. 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.
    2. Composition moves decisions to the caller. A component with a children prop 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.
    3. 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

    A product page fills the header, body and footer slots of a card componentProduct page to Title and badge; Product page to Description; Product page to Reviews link; Title and badge to Card (ReactNode); Description to Card (children); Reviews link to Card (ReactNode)CALLER OWNS THE CONTENTProduct page: decides the contentProduct pagedecides thecontentTitle and badge: header slotTitle andbadgeheader slotDescription: childrenDescriptionchildrenReviews link: footer slotReviews linkfooter slotCard: layout onlyCardlayout onlyReactNodechildrenReactNode

    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

    sequenceDiagram accTitle: A tab click updating a panel through shared context accDescr: The user clicks a tab, the tab calls select from context, the Tabs root updates its state, and the context value changes so the tabs and panels that read it render again. participant U as User participant T as Tabs.Tab participant R as Tabs root participant P as Tabs.Panel U->>T: click "Reviews" T->>R: select("reviews") from context R->>R: set state, new context value R-->>T: re-render (aria-selected updates) R-->>P: re-render (shows or hides)

    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

    sequenceDiagram accTitle: Controlled versus uncontrolled value ownership accDescr: In the uncontrolled case the component stores the value and reports the change. In the controlled case the component only reports the change and the parent decides what value to pass back. participant U as User participant C as Component participant P as Parent Note over C,P: Uncontrolled (defaultValue) U->>C: choose "reviews" C->>C: store "reviews" C->>P: onChange("reviews") Note over C,P: Controlled (value) U->>C: choose "reviews" C->>P: onChange("reviews") P->>C: value = "details" (parent decides)

    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

    flowchart TD accTitle: Choosing a composition tool for a component API accDescr: A decision tree. If the caller only supplies content, use children or slots. If parts must share hidden state, use a compound component. If the component owns data the caller renders, use a render prop. If a value may be owned by the parent, support controlled and uncontrolled. Q1{"What does the
    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.

    1. Read SprawlCard and Card in src/components.tsx. Add a new requirement (an icon next to the title) to each and count what you must change.
    2. Run the tests and read the Tabs test names: uncontrolled, controlled, a parent that redirects a change, a switch reported once, a part outside its parent throwing.
    3. Predict how often Expensive renders after two clicks in each wrapper (the tests say 1 and 3), and the FragileList item count for MoreRows.

    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

    NOTE

    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

    Six teams all pass props to one ProductCard that contains every variationSearch page to ProductCard (props); Home page to ProductCard (props); Cart upsell to ProductCard (props); ProductCard to One review gate (every change)CALLERS (SIX TEAMS)Search pageSearch pageHome pageHome pageCart upsellCart upsellProductCard: 14 props, many branchesProductCard14 props, manybranchesOne review gate: all changesOne reviewgateall changespropspropspropsevery change

    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

    1. Add the new Card beside the old component. Nothing is removed in the first release.
    2. Migrate one page per team, starting with the most contradictory props, and compare screenshots in each team's visual tests.
    3. 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 children would 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.
    • children and ReactNode props are holes the caller fills; the component owns layout.
    • Pass JSX as children before 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 children with the Children API 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.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments