skip to content

A design-system `RadioGroup` gives its options a shared `name` and `checked` value by walking `React.Children.map` and calling `cloneElement` on each child. What breaks as real consumers adopt it, and what would you build instead?

level: seniorimportance: nice to knowfreq 30%

answer

  1. only the direct children get cloned
  2. a wrapper eats the injected props
  3. new props win the merge
  4. the contract has no type
  5. the caller's JSX shape is the API

basics

~20 s

Injection by cloneElement only reaches direct children, so any wrapper, fragment or conditional silently stops it and options lose their name. It also overrides caller-supplied props invisibly and is untyped, so a data-driven prop or an explicit shared-value API is the sturdier design.

solid answer

~50 s

The pattern works in the demo and erodes in production. `Children.map` sees only top-level children, so the moment a consumer wraps an option in a `<div>` for layout, groups two in a fragment, or renders options from their own `.map` inside a wrapper component, the clone lands on the wrapper instead of the option — the injected `name` becomes a stray DOM attribute and the radio group quietly stops behaving as a group. Injection is also invisible in the types: the child's props say `name` is optional because the parent supplies it, so nothing catches a mistake. And `cloneElement(el, props)` merges the new props over the original, so a caller's explicit value is silently overridden. The sturdier designs are a data-driven prop (`options={[…]}` and the group renders them) or a shared-value mechanism the options read for themselves.

code

jsx · 16 lines
jsx
import { Children, cloneElement, isValidElement } from 'react';

function Group({ name, children }) {
  return Children.map(children, child =>
    isValidElement(child) ? cloneElement(child, { name }) : child
  );
}

export default function App() {
  return (
    <Group name="plan">
      <input type="radio" value="a" />
      <div><input type="radio" value="b" /></div>
    </Group>
  );
}

go deeper

for a junior

Know that cloneElement returns a copy of an element with extra props merged in and that elements themselves are never mutated. You are unlikely to be asked to defend the pattern at this level.

for a middle

Explain the merge rule — new props override the original, key is preserved unless replaced — and that Children.map reaches only top-level nodes, so wrappers and fragments break injection.

for a senior

Diagnose it as an API defect rather than a caller mistake: the component's contract depends on the caller's JSX shape, fails silently, and cannot be typed. Propose the data-driven or child-reads-shared-value alternative with its tradeoffs.

for a principal

Own the versioning consequence — an undocumented "must be a direct child" rule becomes de facto public API, consumers build workarounds on it, and you cannot fix it without a breaking change. Decide such constraints deliberately and document them at ship time.

## The pattern under discussion ```jsx import { Children, cloneElement, isValidElement } from 'react'; function RadioGroup({ name, value, onChange, children }) { return ( <fieldset> {Children.map(children, child => isValidElement(child) ? cloneElement(child, { name, checked: child.props.value === value, onChange }) : child )} </fieldset> ); } ``` It reads beautifully at the call site: `<RadioGroup name="plan"><Radio value="a" /><Radio value="b" /></RadioGroup>`. That is why it keeps getting written. ## Failure 1: traversal is one level deep `Children.map` visits only the top-level nodes of `children`. It does not descend into a rendered element, and it does not traverse into a fragment. So all of these break: ```jsx <RadioGroup name="plan"> <div className="row"><Radio value="a" /></div> {/* div gets the props */} <>{<Radio value="b" />}</> {/* fragment gets the props */} <PlanOptions /> {/* wrapper component gets them */} </RadioGroup> ``` When the clone lands on a host element such as `<div>`, React passes unknown props through toward the DOM, so `name` becomes an attribute and `onChange` a handler on the wrong node; when it lands on a component that ignores them, absolutely nothing happens. Either way the radios lose their shared `name`, so the browser no longer treats them as one group and two can be selected at once. There is no error message anywhere. ## Failure 2: merge order is invisible `cloneElement(element, props)` returns a copy whose props are the original's merged with the new ones — **the new ones win** — while the original `key` is preserved unless the new props specify one. A consumer who writes `<Radio value="a" name="custom" />` sees their value discarded with no warning. The reverse design (original wins) is no better; either way the call site cannot tell which of the two props on screen is the one in effect. ## Failure 3: nothing type-checks For `<Radio value="a" />` to be legal at the call site, `Radio` must declare `name`, `checked` and `onChange` as optional — which is a lie everywhere else `Radio` is used, and it turns a required-prop error into a runtime nothing-happens. The contract "my parent will fill these in" cannot be expressed in the child's own type. ## Failure 4: it constrains markup forever Once shipped, this component has an undocumented rule: *options must be immediate children*. Every layout change a consumer makes is a potential silent regression, and you cannot fix it later without breaking the people who worked around it. React's own documentation treats `cloneElement` as a legacy API for essentially this reason — it is not deprecated, but it is not what new APIs should be built on. ## What to build instead **Data-driven props.** If the group knows the whole option set, let it render them: ```jsx <RadioGroup name="plan" value={value} onChange={setValue} options={[{ value: 'a', label: 'Basic' }, { value: 'b', label: 'Pro' }]} /> ``` Now the parent controls every option's props by construction, nesting is irrelevant, and the option shape is fully typed. The cost is customization: callers can no longer drop arbitrary markup between options, so expose the customization you actually need (a `renderLabel`, an `optionClassName`) rather than an open slot. **Let the child ask for the shared value.** The other direction inverts the flow: the group publishes `name`, `value` and `onChange` once, and each option reads them itself, no matter how deeply it is nested. That removes the depth constraint entirely and is the standard approach for implicit parent/child component APIs — the mechanics of that pattern are a topic of their own. **Or narrow the scope honestly.** If you keep the clone-based version, document that options must be direct children, assert it (`isValidElement` plus a check on `child.type`, warning in development when it fails), and keep it internal to your own package rather than public API. ## The point to make in the interview The defect is not that `cloneElement` is slow or exotic — it is that it makes a component's contract depend on the *shape of the caller's JSX*, and that contract has no type, no error message, and no way to evolve. Sturdy APIs move the shared value either down as data the parent constructs, or sideways as a value the child reads.

  • If a clone lands on a `<div>` instead of the intended component, what does the user actually see?
    Usually nothing obvious, which is the problem. React forwards unrecognised props on host elements toward the DOM, so the div picks up a `name` attribute and a handler that never fires meaningfully, while the real input keeps its old props. The group behaviour degrades — two radios selectable at once, for example — with no error to trace back.
  • When is `cloneElement` still a defensible choice?
    When the element being cloned is one you created moments earlier in the same component and fully control, so no caller JSX is involved — adding a key or a class to a node you built yourself. The fragility comes from cloning *someone else's* elements. Even then, constructing the element with the right props directly is usually clearer.
  • How would you keep the nice `<RadioGroup><Radio /></RadioGroup>` call site without the depth constraint?
    Have the group publish the shared values once and have each `Radio` read them itself, so nesting depth stops mattering — the option gets its `name` whether it sits at the top level or inside three wrappers. That is the implicit parent/child API shape, and its own tradeoffs around discoverability and coupling are a separate discussion.

saying these in an interview costs you the question

  • Says cloneElement is fine because it only mutates a copy
  • Believes Children.map descends into wrappers and fragments
  • Assumes the caller's explicit prop survives the clone
  • Thinks TypeScript catches a missing injected prop
  • Treats a silent missing prop as a caller mistake, not an API defect

context