DEV Community

Marvin
Marvin

Posted on • Updated on

A tale of bounded components

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">
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  </>
)
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}
  </>
)
Enter fullscreen mode Exit fullscreen mode

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 })
  )
}
Enter fullscreen mode Exit fullscreen mode

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 TabHeads and the Tabs.

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) })
  )
Enter fullscreen mode Exit fullscreen mode

TabHead will simply look like this:

const TabHead = ({ selectIndex, ...props }) => (
  <button onClick={selectIndex} {...props} />
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)