loading...

React.Fragment, the only child

nicolasamabile profile image Nicolas Amabile ・2 min read

This is a short post about some issues I had while building a wizard component in ReactJS.

  • You can't reference a "falsy" child while using React.cloneElement.
  • React.Fragment returns a single child.

At the beginning my wizard instance looked something like this:

<Wizard>
  <Step1 />
  <Step2 />
  <Step3 />
  <Step4 />
  <Step5 />
</Wizard>

Behind the scenes, the component will only render the current step.

render () {
  const { children } = this.props
  const { activeStep } = this.state
  const extraProps = {...} // Some extra info I need on each step.
  return (
    …
    {React.cloneElement(children[activeStep], extraProps)}
    …
  )
}

Based on some business rules, I wanted to hide/show some steps, so my wizard instance will look something like this:

renderStep2 () {
  if (conditionForStep2) {
    return <Step2 />
  }
}
render () {
  return ( 
    <Wizard>
      <Step1 />
      {this.renderStep2()}
      <Step3 />
      {conditionForStep4 && <Step4 />}
      <Step5 />
    </Wizard>
  )
}

Those expressions evaluate to undefined for Step2 and false for Step4, and any of those values can be used as a valid child when doing React.cloneElement(children[activeStep], extraProps) where activeStep is the index of Step2 or Step4, React will complain 😩 and also my index will be wrong.
React error. Element type is invalid

Instead of using children directly, I created a function that returns only the "truthy" steps:

const getChildren = children => children.filter(child => !!child)
And change my Wizard render function to something like this:
render () {
 const { children } = this.props
 const { activeStep } = this.state
 const filteredChildren = getChildren(children)
 return (
   …
   {React.cloneElement(filteredChildren[activeStep], extraProps)}
   …
 )
}

The first problem solved 🎉

I got to the point where I wanted to group some steps in order to simplify my logic. Let's say for example that I need to use the same condition for rendering Step3, Step4 and Step5, so I grouped them into a React.Fragment.

renderMoreSteps () {
  if (condition) {
    return (
      <Fragment>
        <Step3 />
        <Step4 />
        <Step5 />
      </Fragment>
    )
  }
}

And my Wizard instance:

<Wizard>
  <Step1 />
  <Step2 />
  {this.renderMoreSteps()}
</Wizard>

The problem: Even though Fragment is not represented as DOM elements, it returns a single child instead of individual child components.
The solution: flatten children.

import { isFragment } from 'react-is'
const flattenChildren = children => {
  const result = []
  children.map(child => {
    if (isFragment(child)) {
      result.push(…flattenChildren(child.props.children))
    } else {
      result.push(child)
    }
  })
  return result
}

Updated getChildren function:

const getChildren = children => flattenChildren(children).filter(child => !!child && !isEmpty(child))

For simplicity, I used react-is, but the implementation is straight forward:

function isFragment (object) {
  return typeOf(object) === REACT_FRAGMENT_TYPE
}
const REACT_FRAGMENT_TYPE = hasSymbol
  ? Symbol.for('react.fragment')
  : 0xeacb;
const hasSymbol = typeof Symbol === 'function' && Symbol.for;

I hope this helps!
All comments are welcomed.

Posted on Apr 21 '19 by:

nicolasamabile profile

Nicolas Amabile

@nicolasamabile

Software dev, musician, kickboxer & bad joke teller.

Discussion

markdown guide