Advanced React component composition

A deep dive on composition in React. Learn key principles for designing and building reusable components that scale.

Rem · 19 Aug 2022

Share:

Introduction

The component model is at the heart of React. Its influence spreads to most frontend frameworks today, becoming the de facto way to structure modern frontend applications.

The declarative component model has since spread to native mobile development, with IOS’s Swift UI and Android’s Jetpack compose. Like all things that seem obvious in hindsight, composing components is an excellent way to structure frontends.

The composition of independent components is the primary tool to fight against the rapid rise of complexity as projects grow. It helps to break stuff down into understandable pieces.

This post is a follow-up to building future-facing frontends. We talked a lot about the circumstances leading to the opposite of composable components.

That is, monolithic components. Which don’t compose well, become slow, and are risky to change over time. Often leading them to be duplicated somewhere else and changed slightly when new requirements roll in.

This post will dive deep into the main principles used to break down components and design composable APIs. By the end, we’ll be able to embrace this powerful composition model effectively when building components that are meant to be reused.

Equipped with these principles, we’ll take a stab at designing and implementing a trusty staple of any shared component library, a Tabs component. Understanding the core problems and trade-offs we’ll need to tackle along the way.

What’s a composition based API?

Let’s take a look at HTML, one of the original “declarative UI” technologies. A common example is the native select element:

<select id="cars" name="cars">
  <option value="audi">Audi</option>
  <option value="mercedes">Mercedes</option>
</select>

When we apply this composition style to React elements, it’s called the “compound component” pattern.

The main idea is that multiple components work together to achieve the functionality of a single entity.

When we talk about APIs, we can think of props as the public API of a component, and components as the API of a package.

Like anything difficult and ambiguous, good API design often involves iteration over time based on feedback.

Part of the challenge is that an API will naturally have different types of consumers. Some percentage will just want the simple use case. Others will require some degree of flexibility. At the tail end, some will require deeper customizations for use cases that are hard to foresee.

For many commonly used frontend components, a composition-based API is a good defense against these unforeseen use-cases and changing requirements.

Designing composable components

A key question is how do we break down components to the right level?

In a purely bottom-up approach, we risk creating too many small components that are practical to use.

In a top-down approach (which tends to be more common) - not enough. Leading to large monolithic components that take in too many props, and try to do too much, which quickly become unmanageable.

Whenever we’re faced with an ambiguous problem, it’s good to start with the end user we’re solving the problem for.

A principle that helps with component API design is the stable dependency principle.

There are two main ideas here:

  1. As consumers of a component or package, we want to be able to depend on things with a high chance of remaining stable over time, allowing us to get our job done.

  2. Inversely, as makers of a component or package, we want to encapsulate things that are likely to change to protect consumers from any volatility.

Let’s apply this principle to our old friend, the Tabs component.

How do we determine what components are stable?

Unless our entire conception of what Tabs are changes, it’s a relatively safe bet to form our components around the main elements we can see when looking at it visually.

That’s one of the nice things about designing APIs for commonly used UI elements compared to more abstract entities.

In this case, we can imagine a row of Tabs in a list (that you click on to change content). And an area of content that changes based on the currently selected tab.

Here’s what the end API of our tabs package might look like based on this (the same API found in Reakit and other similar open source component libraries).

import { Tabs, TabsList, Tab, TabPanel } from '@mycoolpackage/tabs'
;<Tabs>
  <TabsList>
    <Tab>first</Tab>
    <Tab>second</Tab>
  </TabsList>
  <TabPanel>hey there</TabPanel>
  <TabPanel>friend</TabPanel>
</Tabs>

Nothing too crazy there. Looks just like the HTML select element. The components work together to achieve some functionality and distribute the state under the hood between the components.

Following the stable dependency principle means if we change our approach to all the internal logic, the consumers that import these components don’t need to change anything.

While it’s simple and flexible for consumers, how do we get it to work behind the scenes?

Let’s look at the key challenges we’ll need to solve.

Underlying problems to solve

Internal orchestration between components

Orchestration is the first problem we face whenever we break down things into small independent components.

We want to keep components decoupled. This means they don’t have knowledge of each other, so they can also be reused as standalone sub-components. But we also need them to work together for a common goal.

For example, one of the biggest challenges of micro-services is connecting all the nodes to make them collaborate without creating tight couplings. The same goes for micro frontends.

In our Tabs component, the ordering of the tab panels has embedded the ordering of the elements rendered under the top-level Tabs.

In other words, we need our components to orchestrate under the hood, the correct rendering of content based on what tab is currently selected.

Rendering arbitrary children

Another question is how do we handle arbitrary components that can wrap our components? As an example:

<Tabs>
  <TabsList>
    <CustomTabComponent />
    // etc ...
    <ToolTip message="cool">
      <Tab>Second</Tab>
    </ToolTip>
  </TabsList>
  // etc ...
  <AnotherCustomThingHere />
  <Thing>
    <TabPanel>// etc ..</TabPanel>
  </Thing>
  //...
</Tabs>

Because Tabs and their content are associated by their order in the sub-tree, we need to keep track of the different indexes to handle selecting the next and previous items.

We also need to handle things like managing focus and keyboard navigation, despite any number of arbitrary components wrapping our components. This is a challenge when consumers can render arbitrary markup between our components.

So we’ll need to be able to ignore any intermediate components that aren’t ours to preserve the relative ordering.

There are two main approaches to support this:

  1. Keep track of all of our components in React

    In this approach, we store the elements and their relative ordering in a data structure somewhere.

    This approach gets a bit complex. Because we don’t know by default what position in the tree our components will be, we need to keep track of all the descendant components somehow.

    One way to handle this, is for sub-components to “register” and “deregister” themselves when they mount and unmount, so the top-level parent can access them directly.

    This is the approach component libraries like Reach UI, and Chakra take, it adds a lot of complexity under the hood, but it’s very flexible for consumers. We won’t dive into that approach here.

    To dive deeper into this problem, you can check out how this works in Reach UI.

  2. Read from the DOM

    In this approach, we attach unique ids or data-attribute on the underlying HTML our components render. This allows us to get the next or previous elements by querying the DOM with the indexes stored within those HTML attributes.

    We’re bailing out of React in this case to use the DOM. This is a much simpler approach implementation-wise but breaks away from the idiomatic React style of code.

    Sometimes it’s good to know the rules in-depth first, to know when you can break them. Consumers are encapsulated from this implementation detail. They can just compose the components together and have things just work.

    The great thing about encapsulation and the stable dependency principle is we can hide all the messy details. This is the approach we’ll take in this guide because it’s the simplest.

Implementing our Tabs component

So far we have a component breakdown based on what makes sense for consumers, and understand the main challenges we need to tackle. Let’s now look at how to orchestrate each independent component, so they work together to achieve our Tabs functionality.

Solving the orchestration problem

We can take a few approaches that handle all the behavioral elements under the hood while keeping the external API simple. Let’s take a look at the main approaches:

Cloning elements

React provides an API to clone an element that allows us to pass in new props “under the table”.

This allows our Tabs component to pass all the attributes and event handlers to child components without consumers seeing any of that code.

React.Children.map(children, (child, index) =>
  React.cloneElement(child, {
    isSelected: index === selectedIndex,
    // other stuff we can pass as props under the hood
    // so consumers of this can simply compose JSX elements
  })
)

This is the most common example given in tutorials exploring the compound component pattern.

Limitations with the cloning approach

The issue is we lose flexibility. For example, it won’t work with wrapped components:

  <Tabs>
    <TabsList>
      //can't do this
      <ToolTip message="oops"><Tab>One</Tooltip>
    </TabsList>
  </Tabs>

In this case, we’ll clone ToolTip and not Tab. It’s possible to try and deep-clone all elements, but now you need to recurse through children to find the correct components. This approach can also break if an element doesn’t render children:

  const MyCustomTab = () => <Tab>argh</Tab>

  <TabsList>
    <Tab>hey</Tab>
    <MyCustomTab />
  </TabsList>

In the context of a project that uses something like Typescript, cloneElement is also not fully type-safe. While this approach is the simplest, it’s the least flexible, so we’ll avoid it.

Render props

Another option is to expose the data and attributes from render props.

A render prop exposes all the necessary properties (like an internal onChange for example) back out to consumers who can use that as the “wire” to hook up to their own custom component.

This is an example of inversion of control in practice, where two independent components work together flexibly to achieve some functionality, in other words - composition.

Say for example we have a generic inline editable field with a read view, and edit view it switches to when clicked, where consumers can plug in custom components:

  <InlineEdit
    editView={(props) => (
      // expose the necessary attributes for consumers
      // so they can place them directly where they need to go
      // in order for things to work together
      <SomeWrappingThing>
        <AnotherWrappingThing>
          <TextField {...props } />
        </AnotherWrappingThing>
      </SomeWrappingThing>
    )}}
    // ... etc
  />

This is a great approach for standalone components like this. And this can work for our tabs compound component, but it’s extra work for consumers who want the straightforward, simple case of a tabs component.

It’s worth noting the tabs API Reach UI allows for both regular children and functions as children for ultimate flexibility in its API.

Using React context

This is a flexible and straightforward approach. Where the sub-components read from shared contexts. We’ll take this approach in our implementation.

With this approach, we need to manage the pitfalls of using context. Which is optimizing re-renders by breaking up the state into manageable chunks. To help with this, we can break things up by asking the question: “what is the complete but minimal state for each component”?

Building the components

To keep the examples simple, we’ll omit a bunch of things like styling, type checking, and memoization optimizations like useCallback etc, that a real implementation would have.

Let’s start with the state first. We’ll break up each components state into separate contexts.

const TabContext = createContext(null)
const TabListContext = createContext(null)
const TabPanelContext = createContext(null)

export const useTab = () => {
  const tabData = useContext(TabContext)
  if (tabData == null) {
    throw Error('A Tab must have a TabList parent')
  }
  return tabData
}

export const useTabPanel = () => {
  const tabPanelData = useContext(TabPanelContext)
  if (tabPanelData == null) {
    throw Error('A TabPanel must have a Tabs parent')
  }
  return tabPanelData
}

export const useTabList = () => {
  const tabListData = useContext(TabListContext)
  if (tabListData == null) {
    throw Error('A TabList must have a Tabs parent')
  }
  return tabListData
}

There’s a few things we achieve here by breaking these up into smaller micro states, rather than a single large context store:

  1. Easier to optimize re-renders for smaller chunks of state.
  2. Clear boundaries on what manages what (single responsibility).
  3. If consumers need to implement a totally custom version of Tab, they can import these state management hooks to be used like a “headless ui”. So at the very least we get to share common state management logic.

Each of these context providers will provide the data and accessibility attributes that get passed down into the UI components to wire everything up and build our tabs experience.

Tabs provides the value for TabPanel, and TabsList provides the context value for Tab. Because of this, in our example we need to ensure the individual components are rendered in the expected parent context.

Tab and TabPanel

Our Tab and TabPanel are simple UI components that consume the necessary state from context and render children.

export const Tab = ({ children }) => {
  const tabAttributes = useTab()
  return <div {...tabAttributes}>{children}</div>
}

export const TabPanel = ({ children }) => {
  const tabPanelAttributes = useTabPanel()
  return <div {...tabPanelAttributes}>{children}</div>
}

TabsList

Here’s a simplified version of the TabsList component. It’s responsible managing the list of Tabs that users can interact with to change the content.

export const TabsList = ({ children }) => {
  // provided by top level Tabs component coming up next
  const { tabsId, currentTabIndex, onTabChange } = useTabList()
  // store a reference to the DOM element so we can select via id
  // and manage the focus states
  const ref = createRef()

  const selectTabByIndex = (index) => {
    const selectedTab = ref.current.querySelector(`[id=${tabsId}-${index}]`)
    selectedTab.focus()
    onTabChange(index)
  }
  // we would handle keyboard events here
  // things like selecting with left and right arrow keys
  const onKeyDown = () => {
    // ...
  }
  // .. some other stuff - again we're omitting styles etc
  return (
    <div role="tablist" ref={ref}>
      {React.Children.map(children, (child, index) => {
        const isSelected = index === currentTabIndex
        return (
          <TabContext.Provider
            // (!) in real life this would need to be restructured
            // (!) and memoized to use a stable references everywhere
            value={{
              key: `${tabsId}-${index}`,
              id: `${tabsId}-${index}`,
              role: 'tab',
              'aria-setsize': length,
              'aria-posinset': index + 1,
              'aria-selected': isSelected,
              'aria-controls': `${tabsId}-${index}-tab`,
              // managing focussability
              tabIndex: isSelected ? 0 : -1,
              onClick: () => selectTabByIndex(index),
              onKeyDown,
            }}
          >
            {child}
          </TabContext.Provider>
        )
      })}
    </div>
  )
}

Tabs

Finally, our top-level component. This renders the tabs list and the currently selected tab panel. It passes down the necessary data to TabsList and our TabPanel components.

export const Tabs = ({ id, children, testId }) => {
  const [selectedTabIndex, setSelectedTabIndex] = useState(0)
  const childrenArray = React.Children.toArray(children)
  // with this API we expect the first child to be a list of tabs
  // followed by a list of tab panels that correspond to those tabs
  // the ordering is determined by the position of the elements
  // that are passed in as children
  const [tabList, ...tabPanels] = childrenArray
  // (!) in a real impl we'd memoize all this stuff
  // (!) and restructure things so everything has a stable reference
  // (!) including the values pass to the providers below
  const onTabChange = (index) => {
    setSelectedTabIndex(index)
  }
  return (
    <div data-testId={testId}>
      <TabListContext.Provider value={{ selected: selectedTabIndex, onTabChange, tabsId: id }}>
        {tabList}
      </TabListContext.Provider>
      <TabPanelsContext.Provider
        value={{
          role: 'tabpanel',
          id: `${id}-${selectedTabIndex}-tab`,
          'aria-labelledby': `${id}-${selectedTabIndex}`,
        }}
      >
        {tabPanels[selectedTabIndex]}
      </TabPanelsContext.Provider>
    </div>
  )
}

The idea is to understand the high-level structure of how things fit together.

For brevity’s sake, we left out a few implementation details that would be required for an actual implementation.

  • Adding a “controlled” version that consumers can hook into implement their own onChange event

  • Extending the API to include default selected tab, tab alignment styles etc etc

  • Optimizing re-renders

  • Handling RTL styles and internationalization

  • Making this type safe

  • Option to caching previously visited tabs (we unmount the currently selected tab when we change tabs)

In our example, we assume we get TabsList as the first child of Tabs. This makes an assumption that the Tabs are always at the top. While flexibility is awesome, too much flexibility can be a curse if not managed. There’s a fine balance here.

If this was part of an organization’s more extensive design system, having visual consistency across experiences is important. So we may want to enforce Tabs always being at the top.

It’s a bit more work under the hood to make it completely flexible (like the Reach example), but flexibility comes with the possibility of different variations in user experiences. It’s a trade-off.

A bit later, we’ll touch on how the principle of layering works with composition, where ideally, we would have flexible base components used to construct more specific types of component with opinions baked in. Like the tabs always being at the top.

With that in mind, you might be thinking, that’s a lot of work for a simple tabs component. And you’d be right.

Component API design can be a tricky balance. And a lot goes into the underlying implementation when building truly accessible and reusable components.

This is partly why there is so much potential around the idea of web components, so we don’t have to continually reimplement these types of components. We’re not there yet, and that’s perhaps a story for another day.

Testing our components

How do we test something like this? Where multiple independent components work together.

Generally we want to test from the perspective of the end user. This testing best practice is known as black box testing.

So we’d create test cases that compose the various components together, for the main use-cases, and for more special cases like consumers rendering custom components.

A great comprehensive example of this type of testing can be seen in the test names here in Reach-UI’s implementation of Tabs.

How does this scale?

There are a couple of dimensions to touch on here. One in terms of scaling its reuse across a large project and the other in terms of performance:

  1. Sharing code between teams

    A common situation is a team needing to use an existing component with some variation. If the existing component is not well composed under the hood, it’s often hard to refactor and extend to support the new use case.

    Rather than taking on the risk, it’s common to copy and paste the code to someone safe, make the necessary changes, and use that.

    This leads to duplicated components that are all similar but with slight variations. Leading to a common anti-pattern called “Shotgun surgery”.

    With composition APIs powered by inversion of control, we give up the idea our components can handle every use case and can be reused 100% out of the box. Instead, we focus on reusing the stable core underlying things, which turns out to be much more effective.

  2. Performance

    The first is bundle size. Smaller independent components with clear boundaries make it easier to code-split components that are not immediately required or loaded on interaction, for example. Consumers should only pay for what they use.

    The other is runtime performance (React re-renders). Having independent components makes it easier for React to see what needs to be re-rendered, compared to re-rendering a big monolithic component every time.

Composition all the way up

Let’s understand the process we took so far. We started with a top-level component and built bottom-up towards it. We need to start with a target API before we build bottom up, to know the ideal we’re building towards.

Our Tabs component exists as the composition of smaller, flexible components that build up to it. This pattern applies all the way up to the root of our application.

Features exist from the relationship between the different components composed together. The application exists as the relationship between different features.

Its composition all the way up.

At the risk of getting too philosophical, let’s come back down to earth and see how this relates to the tried and true software engineering principle of layering.

We can understand composition at the higher levels of a React application through the lens of layering:

  1. Base layer: common set of design tokens, constants, and variables used by a shared component library.

  2. Primitive components: and utilities within a component library that composes the base layer to help build up to the components made available in the component library. E.g a Pressable component used internally by a Button and Link.

  3. Shared component library: composes shared utilities and primitive components to provide a set of commonly used UI elements - like buttons and tabs, and many others. These become the “primitives” for the layer above.

  4. Product specific adaptions of commonly shared components: e.g “organisms” that are commonly used within a product that may wrap a few component library components together. That are shared across features within an organization.

  5. Product specific specialized components: For example, in our product the tabs component may need to call out to an API to determine what tabs and content to render. The nice thing about components is that we can wrap it up as a <ProductTabs /> that uses our Tab component underneath.

Recap

We covered a lot of ground in this guide. Let’s finish by recapping the guiding principles for breaking down components and designing composition-based APIs:

  • Stable dependency principle: Is about creating APIs and components with the end user always in mind. We want to depend on things unlikely to change and hide the messy parts.
  • Single responsibility principle: Is about encapsulating a single concern. Easier to test, maintain and importantly - compose.
  • Inversion of control: Is about giving up the idea we can foresee every future use case and empower consumers to plug in their own stuff.

Like always, there’s no silver bullet. And like everything else, flexibility comes with tradeoffs too.

The key is understanding what you are optimizing for and why, so you can mitigate the tradeoffs that come with that decision or simply accept them as the best option compared to others with the available time and resources.

There’s a balance between too little and too much flexibility. In this guide, we were optimizing for a component that can be reused flexibly across teams and features.

The main tradeoff for components like that is the external orchestration required by consumers in order to use the components in the intended way.

This is where clear guidelines, detailed documentation and copy and pastable example code helps mitigate this tradeoff and makes the lives of all developers easier.

References

Want to level up?

Get notified when new content comes out

Feel free to unsubscribe anytime. No spam.