Building reusable UI components is hard. I always rely on an iterative approach and write my component 2-3 times before getting something that I find useful and reusable across my applications. It's kind of a game to find the "good" abstraction and the "good" way to create them.
In A note on composing components with React, I've briefly talked about my conception of composition by taking the example of Google Material Design Cards and how I would have implemented such a thing. This post is an extension of the previous one so I recommend you take a look ☺️.
Today I want to share with you my experience while implementing a UI component library based on a design system and how my team and I have managed to build a bit more complex components.
Let's talk about components that share something.
Radio buttons
I'm going to take the example of radio buttons and this for two reasons.
The first one is that I'm building the UI component library with React Native and that it doesn't provide a built-in Radio component and the second one is because radio buttons are kind of special.
By definition, it's a group of selectable elements where only one element can be selected at a time. Here's a quick link to the MDN definition of radio and radio groups.
In HTML this link is represented by the input
name
attribute:
// this is from MDN
<input type="radio" name="contact" value="email">
<input type="radio" name="contact" value="phone">
<input type="radio" name="contact" value="mail">
I think that we can call these compound semantic elements
If we want to build React components that matches the previous definition of radio elements, these components have to share some information with some other ones.
In the React world, we can say that these components are sharing some state.
To manage this kind of shared state, we can rely on different approaches.
Through the parent state
The simplest thing to do in a React world is to rely on the parent component state.
Let's imagine the following snippet:
const Parent = () => {
const [selectedRadio, selectRadio] = useState(0)
return (
<>
<Radio onPress={() => selectRadio(0)} isSelected={selectedRadio === 0} />
<Radio onPress={() => selectRadio(1)} isSelected={selectedRadio === 1} />
<Text>The selected value is {selectedRadio}</Text>
</>
)
}
This is a fine approach and it works as long as we accept to manage the state of the <Radio />
components in every of their parents.
However, in this code, there is something that we lost: the linked nature of radio buttons. Or at least the family link of the radio elements.
Of course the selected value will be reflected thanks to the parent state. But the radio group is dependent on the parent and not only on itself. On the web platform for example, there are no parent to manage the link between the elements.
Using a global state management tool (let's say Redux)
We can also rely on a global state management tool that will store the actual selected value and provide it across the app:
const mapStateToProps = (state, ownProps) => ({
isSelected: state.selectedRadio === ownProps.name,
})
const mapDispatchToProps = (dispatch, ownProps) => ({
selectMe = () => dispatch({ type: 'SELECT_RADIO', payload: { newSelectedRadio: ownProps.name } })
})
const RadioEnhanced = connect(mapStateToProps, mapDispatchToProps)(Radio)
const Parent = ({ selectedRadio }) => (
<>
<RadioEnhanced />
<RadioEnhanced />
<Text>The selected value is {selectedRadio}</Text>
</>
)
This is also a fine approach and it has the benefit of keeping the linked nature of the Radio element using the global state.
However, we have to define a new Redux key in the store for every different kind of Radio component. We also have to create a reducer for each kind of Radio groups and so forth. And this will be the same even if you don't use Redux but an other global state management system.
React's context API
I often hear that using the React context is a bad practice. I don't totally agree with this statement. I think that we have to understand when not to use it and to use it sparsely. The context is a feature that is built in in React, so they may probably be some good use-cases for it.
What I suggest before writing any component implementation is to imagine your ideal way to use that component. I often start by writing the shape I want it to have (its API), let's say:
const MyComponent = () => {
const [selected, setSelected] = useState('first')
return (
<RadioGroup selected={selected} onChange={setSelected}>
<Radio name="first">
<Text>My first radio</Text>
</Radio>
<Radio name="second">
<Text>My second radio</Text>
</Radio>
</RadioGroup>
)
}
I like this kind of API because it's straightforward to read.
This code represents a group of radio components that act together. When the <Radio name="first" />
is selected, every other radio components in the RadioGroup
children tree will be unselected.
The selected
prop of the RadioGroup
component corresponds to the name
of the selected radio component. If I want to select the first
radio then the code will look like <RadioGroup selected="first">...</RadioGroup>
.
We can create this behavior and feeling of link using React's context API where the RadioGroup
component owns the actual selected name
in its context and share it across its different Radio
children.
This technique of hiding the state management between components is called implicit state passing. We manage the state in a way the developer doesn't have to care about and does not have to implement multiple times.
Here's a running codesandbox of the Radio
and RadioGroup
implementation.
We now have a good understanding of the React context
API. Let's explore another advanced function of React that also allows to pass some implicit states through another example: the Tabs
one.
The Tabs
example
In my posts, I'm talking a lot about the Tabs example that Ryan Florence has provided in this video. It's this video that made me realise that I was doing some things wrong and that I had to understand the "composition" concept.
Tabs
are UI elements that define (UI) interfaces sharing a visual link. They have to be close to each other to provide a good user experience. It doesn't make sense to create a tab at the top left side of the device with another one at the bottom right side.
I like to put Tabs
in the category of layout components: they are not really business oriented nor atomic UI components. They represent a way to display information and how to navigate between different types of information.
We can imagine this components using multiple approaches and one that often comes and that I used to work with was the data driven approach.
Data Driven approach
A data driven approach is a way to build components so that a component requires its props to have a specific shape to be used. For example:
const items = [
{ title: 'First', Component: () => <div>First</div> },
{ title: 'Second', Component: () => <div>Second</div> },
]
const Tabs = ({ items }) => {
const [selected, setSelected] = useState()
let SelectedComponent
if (selected) {
SelectedComponent = items[selected].Component
}
return (
<div>
{items.map((item, index) => (
<div
key={item.title}
onClick={() => setSelected(index)}
style={{ color: selected === index ? 'green' : 'black' }}
>
{item.title}
</div>
))}
{SelectedComponent && <SelectedComponent />}
</div>
)
}
// would be used <Tabs item={items} />
In this example, the Tabs
component knows the shape of each of its item to be able to display them correctly. It's a contract between the object and the component.
While it's okay to work using this approach, I think that it's good to think of a way to avoid this kind of tight coupling. Composition can help to achieve this.
As I have mentioned before, let's image our perfect world API. Something like the following one looks great to me:
<Tabs>
<TabsHeader>
<TabHead>First button</TabHead>
<TabHead>Second button</TabHead>
</TabsHeader>
<TabsBody>
<Tab>
<FirstComponent />
</Tab>
<Tab>
<SecondComponent />
</Tab>
</TabsBody>
</Tabs>
Using React, how can we create the different TabXXX
component so that it works this way?
We could have done this using the context API, but for now, I want to introduce the React.cloneElement
function.
React.cloneElement
This function allows to clone a React element with its actual props with the possibility to override them or to add new ones.
It can be used as following:
const element = <div>Hello world</div>
const clone = React.cloneElement(element, {
style: { backgroundColor: 'red' },
})
const App = () => (
<>
{element}
{clone}
</>
)
We will use this definition of the React.cloneElement
function to provide some props to the children of a component, implicitly.
For example, we will add a props to the TabHead
components so that they become clickable.
We will also add a selectIndex
prop to the TabsBody
component so that he knows which component has to be displayed or not.
Implementing the Tabs
component
The Tabs
component is the owner, the one that knows everything. It owns the actual selected index and knows how to modify that selected index:
const Tabs = ({ children }) => {
const [selectedIndex, setSelectedIndex] = useState(0)
return React.Children.toArray(children).map(child =>
React.cloneElement(child, { selectedIndex, selectIndex: setSelectedIndex })
)
}
In this case React.cloneElement
enhances the child component by adding them two props: the actual selected index and a way to modify that selected index.
In fact, we'll pass down these two props respectively to the TabHead
s and the Tab
s.
The TabsHeader
component will receive the selectIndex
function and will pass it down to its TabHead
children with a subtle variant: we'll scope the actual index of the TabHead
component so that they can call the selectIndex
function without passing their index explicitly:
const TabsHeader = ({ selectIndex, children }) =>
React.Children.toArray(children).map((child, index) =>
React.cloneElement(child, { selectIndex: () => selectIndex(index) })
)
TabHead
will simply look like this:
const TabHead = ({ selectIndex, ...props }) => (
<button onClick={selectIndex} {...props} />
)
The TabsBody
role is to display only the element that matches the selected index. This can be achieved using Array.prototype.find
on the children:
const TabsBody = ({ selectedIndex, children }) =>
React.Children.toArray(children).find((_, index) => selectedIndex === index)
Here's a link to a codesandbox of the previous snippets.
I suggest you take some times to analyse and get familiar with this kind of code. It's something that I wasn't used to before diving into it.
So there we are, these are examples with some explanations of more complex and linked components!
Top comments (0)