React Design Patterns, Part 1 Compound Components.

Bouamar El Ouahhabi
December 17th, 2019 · 3 min read

When i started my journey as a Junior React Developer, i started to build things from scratch and all goes well for me at first. I needed sometimes to reiterate over some components to add more requirements and i was able to refactor them for the first iterations, but after a while it started to be difficult to add functionalities to existing components or to explain the existing code base to a newcomer to the team. Fortunately a senior newcomer had some good solutions for us that we didn’t understand nor accept at first, but with some effort and researches all became clear and pleasant again. These new ways of writing code without repeating ourself and that gives more control to the consumer of the components rather than be usable for one use case. These amusing ways of writing components are called Design Patterns, and we will visit a lot of them in this serie, so prepare yourself to be a rockstar of the react world.

In this serie we are going to learn about different component patterns by building a simple accordion component.

First we will explore a naive implementation, to understand the problem space, here is a link to the starter code.

1// index.js
2
3import React from 'react'
4import { render } from 'react-dom'
5
6import Accordion from './Accordion'
7import { data } from './data'
8
9import 'normalize.css'
10import './styles.css'
11
12const App = () => {
13 return (
14 <div className="App">
15 <Accordion
16 items={data} // Required, array of data objects
17 />
18 </div>
19 )
20}
21
22const rootElement = document.getElementById('root')
23render(<App />, rootElement)
1// Accordion.js
2
3import React, { useState } from 'react'
4import AccordionItem from './AccordionItem'
5import ActiveItem from './ActiveItem'
6
7const Accordion = ({ items }) => {
8 const [activePanel, setActivePanel] = useState(-1)
9
10 const handleClick = id => {
11 const value = id === activePanel ? -1 : id
12 setActivePanel(value)
13 }
14
15 return (
16 <>
17 <div className="panels">
18 {items.map(item => (
19 <AccordionItem
20 key={item.itemId}
21 item={item}
22 onClick={() => handleClick(item.itemId)}
23 activePanel={activePanel}
24 >
25 <p style={{ fontSize: '15pt', color: 'white' }}>{item.title}</p>
26 </AccordionItem>
27 ))}
28 </div>
29 <ActiveItem activePanel={activePanel} items={items} />
30 </>
31 )
32}
33
34export default Accordion

If we assume that we publish the Accordion component to npm and the user/consumer of this component will only import it like this: import Accordion from "accordion";, then it’s flexibility ends at the items prop; we are only able to change the items to display. What if we need to position the AccordionItem bellow ActiveItem?

Let’s try a different approach and rewire the component so it has flexibility and reusability to be used in any future configurations.

Compound Components

Compound components are groupings of smaller components that all work together and are exposed as subcomponents of a single root. Those can be exposed via exports from a folder’s index file, or as properties on a main component.

Example usage exposed from an index file:

1import {
2 ParentComponent,
3 ChildComponent1,
4 ChildComponent2,
5} from 'components/page'
6
7;<ParentComponent>
8 <ChildComponent1>Some content 1.</ChildComponent1>
9 <ChildComponent2>Some content 2.</ChildComponent2>
10</ParentComponent>

Example usage exposed as properties of a single component:

1import { ParentComponent } from 'components/page'
2
3;<ParentComponent>
4 <ParentComponent.ChildComponent1>
5 Some content.
6 </ParentComponent.ChildComponent1>
7 <ParentComponent.ChildComponent2>
8 Some content.
9 </ParentComponent.ChildComponent2>
10</ParentComponent>

Our demos are built using the compound component described in the second example above.

This second example can be acheived by making the child components as static class properties on the larger parent class component

1class ParentComponent extends React.Component {
2 static ChildComponent1 = () => {
3 // code here
4 }
5
6 static ChildComponent2 = () => {
7 // code here
8 }
9
10 // code here
11}

Or if you like the functional way like this

1const ParentComponent = () => {
2 // code here
3}
4
5// This is equivalent to static properties for classes
6
7ParentComponent.ChildComponent1 = () => {
8 // code here
9}
10
11ParentComponent.ChildComponent2 = () => {
12 // code here
13}

Using the compound component pattern, we can let the consumer decide how many or what order they want to render their sub-components. This idea is called inversion of control and it gives more power back to the consumer of your component and this makes your component more flexible and more scalable.

Let’s go back to our Accordion component as you’ve noticed the only thing that we exposed as API is the items prop

1<Accordion
2 items={data} // Required, array of data objects
3/>

This is not a reusable nor a flexible API

Let’s use the magic of compound components and see, but before that we need to understand two small utilities from the React library React.Children.map and React.cloneElement.

React.Children.map is similar to Array.map as it’s used to map over this.props.children and return an array, but as you know this.props.children can be an array if we have more than one child but can also be a single node element if we have one child, so React.Children.map convert the single node element to an array before mapping over it.

Remember this example

1import { ParentComponent } from 'components/page'
2
3;<ParentComponent>
4 <ParentComponent.ChildComponent1>
5 Some content.
6 </ParentComponent.ChildComponent1>
7 <ParentComponent.ChildComponent2>
8 Some content.
9 </ParentComponent.ChildComponent2>
10</ParentComponent>

We use React.Children.map like in the render method here:

1class ParentComponent extends React.Component {
2 static ChildComponent1 = () => {
3 // code here
4 }
5
6 static ChildComponent2 = () => {
7 // code here
8 }
9
10 render() {
11 return React.Children.map(this.props.children, child => {
12 // code here
13 })
14 }
15}

React.cloneElement(element, additionalProps) Clone and return a new React element using element as the starting point. The resulting element will have the original element’s props with the new additionalProps merged in.

1import OriginalComponent from "OriginalComponent";
2
3const NewComponent = React.cloneElement(OriginalComponent, {{ foo: "bar" }})

NewComponent is the clone (same definition) of OriginalComponent except that it has in addition the prop foo.

Using these techniques I am able to design components that are completely reusable, and have the flexibility to use them in a number of different contexts.

Here is the Accordion component with the Compound components pattern:

  • Usage
1// index.js
2
3import React from 'react'
4import { render } from 'react-dom'
5
6import Accordion from './Accordion'
7import { data as items } from './data'
8
9import 'normalize.css'
10import './styles.css'
11
12const App = () => {
13 return (
14 <div className="App">
15 <div className="panels">
16 <Accordion>
17 {items.map(item => {
18 return (
19 <Accordion.AccordionItem key={item.itemId} item={item}>
20 {item.title}
21 </Accordion.AccordionItem>
22 )
23 })}
24 </Accordion>
25 <div />
26 </div>
27 </div>
28 )
29}
30
31const rootElement = document.getElementById('root')
32render(<App />, rootElement)
  • Definition
1// Accordion.js
2
3import React, { Component } from 'react'
4
5class Accordion extends Component {
6 state = {
7 activePanel: -1,
8 }
9 static AccordionItem = ({ children, item, activePanel, onClick }) => {
10 return (
11 <div
12 className={`panel${
13 activePanel === item.itemId ? ' open open-active' : ''
14 }`}
15 style={{
16 backgroundImage: `url("${item.imageUrl}")`,
17 height: '500px',
18 }}
19 onClick={() => onClick(item.itemId)}
20 >
21 <p>{children}</p>
22 </div>
23 )
24 }
25
26 handleClick = id => {
27 const value = id === this.state.activePanel ? -1 : id
28 this.setState({ activePanel: value })
29 }
30
31 render() {
32 const { activePanel } = this.state
33 return React.Children.map(this.props.children, child => {
34 return React.cloneElement(child, {
35 activePanel,
36 onClick: this.handleClick,
37 })
38 })
39 }
40}
41
42export default Accordion

In line 9, we declared AccordionItem as a static proprety of the Accordion class component, so it’s namespaced, and we can access it like this Accordion.AccordionItem by only importing the class Accordion.

In line 33 to 38, we mapped over the childrens of Accordion and add to them the activePanel state as prop by cloning them, so from now on, the activePanel state is implicit and we can’t acceess it when using this API:

1<Accordion>
2 <Accordion.AccordionItem item={item}>{item.title}</Accordion.AccordionItem>
3</Accordion>

Here is a link to the Accordion compond component that implements the above functionalities.

More flexible Compound Components with the help of context API

Suppose for stylistic purpose we want to wrap <Accordion.AccordionItem /> component in a div like this:

1<Accordion>
2 <div className="panels">
3 {items.map(item => {
4 return (
5 <Accordion.AccordionItem key={item.itemId} item={item}>
6 {item.title}
7 </Accordion.AccordionItem>
8 )
9 })}
10 </div>
11 <Accordion.ActiveItem items={items}>{item.title}</Accordion.ActiveItem>
12</Accordion>

This will break our Accordion and the reason is that when we mapped over and cloned children :

1return React.Children.map(this.props.children, child => {
2 return React.cloneElement(child, {
3 activePanel,
4 onClick: this.handleClick,
5 })
6})

When cloning, we gave the additional props to the direct child, which is the div element in the example above not the <Accordion.AccordionItem /> component.

To solve this problem we can use the context API and pass this implicit state to the childs whatever their depth in the hirarchy:

1// Accordion.js
2
3import React, { Component } from 'react'
4
5const AccordionContext = React.createContext({
6 activePanel: -1,
7 onClick: () => {},
8})
9
10class Accordion extends Component {
11 static AccordionItem = ({ children, item }) => {
12 return (
13 <AccordionContext.Consumer>
14 {({ activePanel, onClick }) => (
15 <div
16 className={`panel${
17 activePanel === item.itemId ? ' open open-active' : ''
18 }`}
19 style={{
20 backgroundImage: `url("${item.imageUrl}")`,
21 height: '500px',
22 }}
23 onClick={() => onClick(item.itemId)}
24 >
25 <p style={{ fontSize: '15pt', color: 'white' }}>{children}</p>
26 </div>
27 )}
28 </AccordionContext.Consumer>
29 )
30 }
31
32 static ActiveItem = ({ items }) => {
33 return (
34 <AccordionContext.Consumer>
35 {({ activePanel }) =>
36 activePanel !== -1 ? (
37 <h2>{items.find(({ itemId }) => activePanel === itemId).title}</h2>
38 ) : (
39 <h2>Select a photo</h2>
40 )
41 }
42 </AccordionContext.Consumer>
43 )
44 }
45
46 handleClick = id => {
47 const value = id === this.state.activePanel ? -1 : id
48 this.setState({ activePanel: value })
49 }
50
51 state = {
52 activePanel: -1,
53 onClick: this.handleClick,
54 }
55
56 render() {
57 return (
58 <AccordionContext.Provider value={this.state}>
59 {this.props.children}
60 </AccordionContext.Provider>
61 )
62 }
63}
64
65export default Accordion

From line 5 to 8, we create the AccordionContext, and from line 58 to 60, we gave the AccordionContext.Provider the state as a value, we made the onClick handler in the state for optimisation purpose, and we used this value in the static component with the help of the AccordionContext.Consumer. This way we are able to pass this implicit state in any deeply nested component.

Here is a link to the Accordion compond component with the context API.

In the next part of this serie, we will discuss how we can use render props to achieve the same goals without having to rely on wiring up context to share state between components in our application.

More articles from Obytes

Getting started with Apache Airflow

A great introduction to Apache Airflow. It's well-written and well thought out.

December 13th, 2019 · 5 min read

Monitoring CodePipeline deployments.

Monitoring AWS CodePipeline deployments and report status to Slack.

December 12th, 2019 · 2 min read

ABOUT US

Our mission and ambition is to challenge the status quo, by doing things differently we nurture our love for craft and technology allowing us to create the unexpected.