React Design Patterns, Part 1 Compound Components.
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.
// index.js
import React from 'react'
import { render } from 'react-dom'
import Accordion from './Accordion'
import { data } from './data'
import 'normalize.css'
import './styles.css'
const App = () => {
return (
<div className="App">
<Accordion
items={data} // Required, array of data objects
/>
</div>
)
}
const rootElement = document.getElementById('root')
render(<App />, rootElement)
// Accordion.js
import React, { useState } from 'react'
import AccordionItem from './AccordionItem'
import ActiveItem from './ActiveItem'
const Accordion = ({ items }) => {
const [activePanel, setActivePanel] = useState(-1)
const handleClick = id => {
const value = id === activePanel ? -1 : id
setActivePanel(value)
}
return (
<>
<div className="panels">
{items.map(item => (
<AccordionItem
key={item.itemId}
item={item}
onClick={() => handleClick(item.itemId)}
activePanel={activePanel}
>
<p style={{ fontSize: '15pt', color: 'white' }}>{item.title}</p>
</AccordionItem>
))}
</div>
<ActiveItem activePanel={activePanel} items={items} />
</>
)
}
export 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:
import {
ParentComponent,
ChildComponent1,
ChildComponent2,
} from 'components/page'
;<ParentComponent>
<ChildComponent1>Some content 1.</ChildComponent1>
<ChildComponent2>Some content 2.</ChildComponent2>
</ParentComponent>
Example usage exposed as properties of a single component:
import { ParentComponent } from 'components/page'
;<ParentComponent>
<ParentComponent.ChildComponent1>
Some content.
</ParentComponent.ChildComponent1>
<ParentComponent.ChildComponent2>
Some content.
</ParentComponent.ChildComponent2>
</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
class ParentComponent extends React.Component {
static ChildComponent1 = () => {
// code here
}
static ChildComponent2 = () => {
// code here
}
// code here
}
Or if you like the functional way like this
const ParentComponent = () => {
// code here
}
// This is equivalent to static properties for classes
ParentComponent.ChildComponent1 = () => {
// code here
}
ParentComponent.ChildComponent2 = () => {
// code here
}
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
<Accordion
items={data} // Required, array of data objects
/>
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
import { ParentComponent } from 'components/page'
;<ParentComponent>
<ParentComponent.ChildComponent1>
Some content.
</ParentComponent.ChildComponent1>
<ParentComponent.ChildComponent2>
Some content.
</ParentComponent.ChildComponent2>
</ParentComponent>
We use React.Children.map
like in the render method here:
class ParentComponent extends React.Component {
static ChildComponent1 = () => {
// code here
}
static ChildComponent2 = () => {
// code here
}
render() {
return React.Children.map(this.props.children, child => {
// code here
})
}
}
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.
import OriginalComponent from "OriginalComponent";
const 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
// index.js
import React from 'react'
import { render } from 'react-dom'
import Accordion from './Accordion'
import { data as items } from './data'
import 'normalize.css'
import './styles.css'
const App = () => {
return (
<div className="App">
<div className="panels">
<Accordion>
{items.map(item => {
return (
<Accordion.AccordionItem key={item.itemId} item={item}>
{item.title}
</Accordion.AccordionItem>
)
})}
</Accordion>
<div />
</div>
</div>
)
}
const rootElement = document.getElementById('root')
render(<App />, rootElement)
- Definition
// Accordion.js
import React, { Component } from 'react'
class Accordion extends Component {
state = {
activePanel: -1,
}
static AccordionItem = ({ children, item, activePanel, onClick }) => {
return (
<div
className={`panel${
activePanel === item.itemId ? ' open open-active' : ''
}`}
style={{
backgroundImage: `url("${item.imageUrl}")`,
height: '500px',
}}
onClick={() => onClick(item.itemId)}
>
<p>{children}</p>
</div>
)
}
handleClick = id => {
const value = id === this.state.activePanel ? -1 : id
this.setState({ activePanel: value })
}
render() {
const { activePanel } = this.state
return React.Children.map(this.props.children, child => {
return React.cloneElement(child, {
activePanel,
onClick: this.handleClick,
})
})
}
}
export 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:
<Accordion>
<Accordion.AccordionItem item={item}>{item.title}</Accordion.AccordionItem>
</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:
<Accordion>
<div className="panels">
{items.map(item => {
return (
<Accordion.AccordionItem key={item.itemId} item={item}>
{item.title}
</Accordion.AccordionItem>
)
})}
</div>
<Accordion.ActiveItem items={items}>{item.title}</Accordion.ActiveItem>
</Accordion>
This will break our Accordion and the reason is that when we mapped over and cloned children :
return React.Children.map(this.props.children, child => {
return React.cloneElement(child, {
activePanel,
onClick: this.handleClick,
})
})
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:
// Accordion.js
import React, { Component } from 'react'
const AccordionContext = React.createContext({
activePanel: -1,
onClick: () => {},
})
class Accordion extends Component {
static AccordionItem = ({ children, item }) => {
return (
<AccordionContext.Consumer>
{({ activePanel, onClick }) => (
<div
className={`panel${
activePanel === item.itemId ? ' open open-active' : ''
}`}
style={{
backgroundImage: `url("${item.imageUrl}")`,
height: '500px',
}}
onClick={() => onClick(item.itemId)}
>
<p style={{ fontSize: '15pt', color: 'white' }}>{children}</p>
</div>
)}
</AccordionContext.Consumer>
)
}
static ActiveItem = ({ items }) => {
return (
<AccordionContext.Consumer>
{({ activePanel }) =>
activePanel !== -1 ? (
<h2>{items.find(({ itemId }) => activePanel === itemId).title}</h2>
) : (
<h2>Select a photo</h2>
)
}
</AccordionContext.Consumer>
)
}
handleClick = id => {
const value = id === this.state.activePanel ? -1 : id
this.setState({ activePanel: value })
}
state = {
activePanel: -1,
onClick: this.handleClick,
}
render() {
return (
<AccordionContext.Provider value={this.state}>
{this.props.children}
</AccordionContext.Provider>
)
}
}
export 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.