2

Advanced React component composition

 2 years ago
source link: https://frontendmastery.com/posts/advanced-react-component-composition-guide/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Introduction

The component model is at the heart of React. It’s influence spreading to most frontend frameworks today, becoming the defacto way to structure modern frontend applications.

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

The 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, it turns out composing components is a good way to structure frontends.

This post is a follow up to building future facing front-ends. Where we talked a lot about the circumstances that lead to the opposite of composable components.

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

In this post we’ll dive deep into the main principles used to breakdown components and design composable APIs. So we can embrace the 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 try and apply this style of composition to React, it’s called the “compound component” pattern.

The main idea is multiple components working 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.

Good API design, like anything difficult and ambiguous, 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, there will be some that require deeper customizations for use-cases that are hard to foresee.

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

Designing composable components

How do we break down components to the right level?

In a purely bottom up approach we run the risk of creating too many small components than is 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, that 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’s two main ideas here:

  1. As consumers of a component or package, we want to be able to depend on things that have a high chance of remaining stable over time that allow 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, 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 and 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 actually get it to work behind the scenes?

Let’s take a 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. Which means they don’t have knowledge of each other, so that 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. Same goes for micro frontends.

In our Tabs component the ordering of the tab panels is embedded the ordering of the elements under 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 selected.

Rendering arbitrary children

Another question is how do we handle arbitrary components that can wrap our components?

  <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 be able 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 in-between our components.

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

There are two main approach 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 keep track of all the descendant components somehow.

    The way to handle this is for sub components to “register” and “deregister” themselves to 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 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 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 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 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

There are a few approaches we can take 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 wont 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. For our tabs compound component this can work, but it’s a bit of extra work for consumers who want the straight forward 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 it’s API.

Using React context

This is a flexible and straight forward 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 context.

Which is optimizing re-renders by breaking up the state into manageable chunks. We can break things up using the question “what is the complete, but minimal, state for each component”?

Building the components

In order 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>
  )
}

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 the sake of brevity, there’s quite a few implementation details we left out that would be required for a real implementation.

Some other aspects to consider

- 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 a larger design system within an organization, 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 idea of layering works with composition, where ideally we would have a 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 there’s a lot goes into the underlying implementation when building truly accessible and re-usable 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’s a couple of dimensions to touch on here, one in terms of scaling it’s reuse across a large project, and one in terms of performance:

  1. Sharing code between teams

    A common situation is a team needing to use an existing component, with a 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 and 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 getting re-use of the stable core underlying things, which turns out to be much more effective.

  2. Performance

    First is bundle size. Having smaller independent components with clear boundaries makes 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). Where having independent component makes it easier for React to see what needs to be re-rendered, compared re-rendering a big monolithic component every time.

Composition all the way up

The idea is we start with a top level component and build bottom up towards it. We need to start with a target before we build bottom up to know what we’re building towards.

Our Tabs component exists as the composition of smaller 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.

It’s 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 lense 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 a 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 underneath uses our Tab component.

There’s no hard and fast rules there.

It’s just to illustrate how the principles we applied to building a component in the context of a reusable library, also apply up to the higher levels of a frontend architecture as a whole. Where the higher layer compose the primitives exposed by the layer 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 trade-offs too.

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

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 trade-off 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.

References


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK