react: spreading children vs cloneelement

2020-09-03

 | 

~4 min read

 | 

621 words

I recently came across some code making use of the cloneElement function from React to pass along the children in the render function. Here’s a small example:

brokenButtonGroup.js
function Buttons(props) {
  const [selected, setSelected] = useState("None")
  return (
    <>
      <h2>The current selected item is {selected}</h2>
      {props.children}
    </>
  )
}

function App() {
  return (
    <Buttons>
      <button value="A">A</button>
      <button value="B">B</button>
      <button value="C">C</button>
    </Buttons>
  )
}

What’s the problem here? Well, setSelected is never called and the buttons don’t have an onClick! So, even while the buttons are clickable, the state is never set and as a result our current selected remains "None".

Two solutions jump out as feasible:

  1. Lift the state up to the App level and add the onClick to the buttons directly
  2. Somehow mutate the children components coming in and attach a click handler.

Lifting State

This is the solution I’m most comfortable with. Lifting the state up to the level of the App component and then managing it Buttons can become a dumb functional component.

managedButtonGroup.js
function Buttons(props) {
  return (
    <>
      <h2>The current selected item is {props.selected}</h2>
      {props.children}
    </>
  )
}

export default function App() {
  const [selected, setSelected] = useState("None")
  return (
    <Buttons selected={selected}>
      <button onClick={(e) => setSelected(e.target.value)} value="A">
        A
      </button>
      <button onClick={(e) => setSelected(e.target.value)} value="B">
        B
      </button>
      <button onClick={(e) => setSelected(e.target.value)} value="C">
        C
      </button>
    </Buttons>
  )
}

There are some drawbacks to this approach though:

  1. Most egregiously, the state is now managed in a component that otherwise has no concept of what “selected” means. There’s nothing about App that suggests that’s where I’d want to manage the props for Buttons.
  2. It leads to a lot of potential duplication - notice the click handlers are now copied three times - each repetition leads to a potential for a bug to creep in later when that handler changes.

Mutating Children

Instead of lifting the state, there’s an alternative approach - but it means no longer simply spreading the children props as in the original approach, but cloning them. This is necessary because as Joe Maddalone explains in his EggHead.io video, Use React.cloneElement to Extend Functionality of Children Components, ”props.children isn’t the actual children. It’s a descriptor of the children…”

Practically, then, we need to clone the descriptor into an actual component which we can then adjust accordingly.

clonedButtonGroup.js
function Buttons(props) {
  const [selected, setSelected] = useState("None")

  const buttons = props.children.map((child) =>
    React.cloneElement(child, {
      onClick: (event) => setSelected(event.target.value),
    }),
  )

  return (
    <>
      <h2>The current selected item is {selected}</h2>
      {buttons}
    </>
  )
}

export default function App() {
  return (
    <Buttons>
      <button value="A">A</button>
      <button value="B">B</button>
      <button value="C">C</button>
    </Buttons>
  )
}

In the above example, we took each child and mapped over the list appending an onClick prop to each.

It’s worth noting that this will break if you try to clone an element that’s not a valid React element (e.g., plain text). In that case, you may avoid this by using the isValidElement top level React API prior to mutation (similarly, you can add any other conditional checks as necessary):

clonedButtonGroupWithLogic.js
const buttons = props.children.map((child) => {
+    if (!React.isValidElement(child)) {
+        return child
+    }
    return React.cloneElement(child, {
        onClick: (event) => setSelected(event.target.value),
    })
})

Wrap Up

The cloneElement API is not something you’ll likely need to use every day. In fact, as I demonstrated, there’s almost always an alternative approach.

That said, what I like about cloneElement is how it clarifies the intent and allows related logic to stay together. One of my favorite features of working with React is that it enables the business logic of a component to be co-located with the markup. cloneElement is just one more tool in the toolbox for enabling that!



Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!