ag

Compound components in React

🌿 This post is still growing and might be updated.

Sometimes you find yourself in a situation where you need to build a component that is reusable but also very flexible so it can adapt to a myriad of situations. It could be something like a toggle, tabbed navigation, or an accordion. A pattern that I think is fantastic for solving these problems is compound components. That is when you have multiple components working together in a coupled but flexible way.

Let’s look at how we could build this accordion as a compound component:

Hey you! I'm a interactive example! Try expanding the other items or closing this one!

This accordion consists of three components: One for the toggling button, one for the collapsible content, and one parent that ties everything together. Here’s how it looks when using it:

<Accordion>
  <div>
    <Accordion.Toggle eventKey="item1">Item 1</Accordion.Toggle>
    <Accordion.Content eventKey="item1">Content 1...</Accordion.Content>
  </div>
  <div>
    <Accordion.Toggle eventKey="item2">Item 2</Accordion.Toggle>
    <Accordion.Content eventKey="item2">Content 2...</Accordion.Content>
  </div>
  <div>
    <Accordion.Toggle eventKey="item3">Item 3</Accordion.Toggle>
    <Accordion.Content eventKey="item3">Content 3...</Accordion.Content>
  </div>
</Accordion>

There’s a couple of things to note here. First off <Accordion.Toggle> and <Accordion.Content> might look a little weird with it if you haven’t seen it before, but it is because the <Toggle> and <Content> components are added as static properties on <Accordion>. This is not a requirement in any way but I think it makes it clearer that we are dealing with tightly coupled components intended to be used together.

Secondly neither the <Toggle> and <Content> components are direct children of <Accordion>. This is possible because we use a Context to pass data between our components, which gives us a lot of flexibility in how we want our accordion to look and work.

Here is how the parent component <Accordion> is implemented:

const AccordionContext = React.createContext()

function Accordion({ children }) {
  const [activeKey, setActiveKey] = useState(null)

  const setKey = (newKey) => {
    if (newKey === activeKey) setActiveKey(null)
    else setActiveKey(newKey)
  }

  return <AccordionContext.Provider value={{ activeKey, setKey }}>{children}</AccordionContext.Provider>
}

There’s a bit to unpack here so let’s start at the top. We create a context called AccordionContext that will be used to connect our components. Then we set up some state, activeKey, to hold the currently open “tab”s key and create a function that updates our state to open/close the accordion. Lastly, we return the component’s children wrapped in our contexts <Provider> which value we set to an object with activeKey and setKey.

With that set up we can take a look at <Content> where the implementation looks like this:

function Content({ children, eventKey }) {
  const { activeKey } = useContext(AccordionContext)

  if (activeKey !== eventKey) return null

  return children
}

We get activeKey from the AccordionContext and if it doesn’t match the prop eventKey we return null, otherwise we return children.

Finally, this is how <Toggle> looks:

function Toggle({ children, eventKey }) {
  const { setKey } = useContext(AccordionContext)

  return <button onClick={() => setKey(eventKey)}>{children}</button>
}

Here we grab setKey from our AccordionContext and when the user clicks the button we call it with the toggles eventKey as its only argument.

Sometimes you might want to give the user the option of what element the toggle should be and one way of adding that flexibility would be to use a render prop. We could also use another pattern where you can control the element type with a prop. Usually this prop is called as. In our example it would look like this:

function Toggle({ children, eventKey, as = 'button' }) {
  const { setKey } = useContext(AccordionContext)

  return React.createElement(as, {
    onClick: () => setKey(eventKey),
    children: children,
  })
}

To achieve this we use the cloneElement function from Reacts top-level API and with this, we can pass any element type to Toggle and it will just work.

And with that our accordion is complete! You can find the complete implementation here.

So to recap: By splitting the implementation across multiple components that share an implicit state using Context we’ve created a more expressive and flexible API for our accordion. If we suddenly decide we want to have the accordion buttons below the content we only need to change the usage and not the implementation. This is one of the biggest benefits of compound components.

Hopefully this has given you some insight into why and when compound components are useful. I think it should be a tool in every React developers toolbox.

Glad you want to learn more! It is possible to create compound components without using context, with the caveat that it’s a little less flexible and a bit more complex.

Instead of connecting our components with a context we can use React.cloneElement to pass activeKey and setKey as props from the parent to its children:

function Accordion({ children }) {
  const [activeKey, setActiveKey] = useState(null)

  const setKey = (newKey) => {
    if (newKey === activeKey) setActiveKey(null)
    else setActiveKey(newKey)
  }

  return React.Children.map(children, (child) =>    React.cloneElement(child, {      setKey,      activeKey,    })  )}

This works but with the big caveat that it requires <Toggle> and <Content> to be direct children of <Accordion>, and with that we lose the flexibility to structure our markup however we want.

Another drawback is that we now pass both activeKey and setKey to all children. We could work around this by checking the type of child before cloning it but this also increases the complexity of our implementation and for no apparent benefit.

So while it is possible to create compound components without a context there is little to no benefit to doing so if Context is an alternative.

You’re more than welcome to hit me up on Twitter if you have any suggestions or feedback!

Thanks for reading!

DEV.to badgeDiscuss on DEV

WebMentions

What's this?
Nothing's here yet! Tweet about this post to show up here.