Frontend Development 22 min read

Mastering Composable React Components: Build a Reusable Tabs UI

This article explains core principles for designing composable React APIs, demonstrates how to decompose UI into stable, reusable parts, and walks through a complete implementation of a flexible Tabs component with context, render‑props, and testing strategies.

KooFE Frontend Team
KooFE Frontend Team
KooFE Frontend Team
Mastering Composable React Components: Build a Reusable Tabs UI
This article is a translation of "Advanced React component composition"; click the link at the bottom to view the original.

Componentization is the core idea of React and has spread to many front‑end frameworks, becoming the standard way to build modern applications. By composing independent components we can tame the rapid growth of complexity, a practice also used in mobile development such as Swift UI and Jetpack Compose.

The opposite of composable components are "big‑stone" components that become bloated, hard to combine, and risky to modify, often leading developers to copy‑paste code for reuse.

This article explores the main principles for breaking down components and designing composable APIs, then applies them to create a trustworthy, reusable Tabs component as a concrete example.

What Is a Composition‑Based API?

HTML is a declarative language that describes page content by composing elements. The following example shows a

<select>

element implementing a dropdown list:

<code>&lt;select id="cars" name="cars"&gt;
  &lt;option value="audi"&gt;Audi&lt;/option&gt;
  &lt;option value="mercedes"&gt;Mercedes&lt;/option&gt;
&lt;/select&gt;</code>

When this idea is applied in React it is called the “compound component” pattern: multiple components work together to deliver a specific feature.

In API terms, a component’s

props

are its public API, while the component itself is a package API. Good API design requires continual iteration based on feedback, because consumers vary—from simple use‑cases to highly flexible or deeply customized scenarios. A composition‑based API helps accommodate unpredictable requirements.

Designing Composable Components

Finding the right granularity is key. A purely bottom‑up approach creates too many tiny components, while a naive top‑down approach yields monolithic components with excessive props. Starting from the end‑user perspective and designing the component API first leads to better outcomes.

The "stable dependency principle" guides API design with two ideas:

Consumers want the parts they depend on to remain stable over time.

Developers should encapsulate anything that may change, shielding consumers from volatility.

We will apply this principle when building the Tabs component.

Defining the Tabs API

Visually, a Tabs UI consists of a list of tab buttons and a content area that shows the panel for the selected tab. The API mirrors this structure and is similar to the one used by Reakit and other open‑source libraries:

<code>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>
</code>

Like the HTML

&lt;select&gt;

, these components compose to implement a feature while keeping state localized.

Challenges in Internal Logic

After decomposing components we must implement their interaction logic without coupling them. The component tree must maintain the correct order, manage focus, support keyboard navigation, and allow arbitrary children to be inserted.

Two common strategies are:

Register each component in React during mount/unmount so the parent can directly access them (used by Reach UI and Chakra).

Read the DOM using unique IDs or data attributes to locate previous/next elements.

Both approaches have trade‑offs; the DOM‑reading method is simpler to implement in React, though it diverges from typical React patterns.

Implementation Using React Context

We split state into small contexts to keep responsibilities clear and enable selective re‑renders:

<code>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
}
</code>

Each component consumes the appropriate context:

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

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

The

TabsList

component manages the list of tabs, handles focus, and provides a

selectTabByIndex

helper:

<code>export const TabsList = ({ children }) => {
  const { tabsId, currentTabIndex, onTabChange } = useTabList()
  const ref = createRef()
  const selectTabByIndex = index => {
    const selectedTab = ref.current.querySelector(`[id=${tabsId}-${index}]`)
    selectedTab.focus()
    onTabChange(index)
  }
  // keyboard handling omitted for brevity
  return (
    <div role="tablist" ref={ref}>
      {React.Children.map(children, (child, index) => {
        const isSelected = index === currentTabIndex
        return (
          <TabContext.Provider value={{
            key: `${tabsId}-${index}`,
            id: `${tabsId}-${index}`,
            role: 'tab',
            'aria-setsize': children.length,
            'aria-posinset': index + 1,
            'aria-selected': isSelected,
            'aria-controls': `${tabsId}-${index}-tab`,
            tabIndex: isSelected ? 0 : -1,
            onClick: () => selectTabByIndex(index),
            onKeyDown,
          }}>
            {child}
          </TabContext.Provider>
        )
      })}
    </div>
  )
}
</code>

The top‑level

Tabs

component wires everything together and renders the currently selected panel:

<code>export const Tabs = ({ id, children, testId }) => {
  const [selectedTabIndex, setSelectedTabIndex] = useState(0)
  const childrenArray = React.Children.toArray(children)
  const [tabList, ...tabPanels] = childrenArray
  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>
  )
}
</code>

Beyond the core implementation, a production‑ready component would add a controlled version, default selected tab, alignment options, performance optimizations, RTL support, type safety, and caching of visited tabs.

Testing the Component

Testing focuses on black‑box, user‑centric scenarios: combining the various sub‑components, handling custom children, and ensuring accessibility. Reach UI’s Tabs tests serve as a detailed reference.

Extending and Performance Considerations

In large projects, teams often copy‑paste components to avoid risk, leading to “shotgun” modifications. A composable API reduces this by encapsulating changeable parts and exposing stable hooks for reuse.

Performance benefits include smaller bundle sizes—tiny, well‑bounded components can be code‑split—and better runtime performance because React can more precisely determine what needs re‑rendering.

Upward Composition and Layered Architecture

The approach starts with a top‑level component and builds upward through layers: base utilities → foundational components → shared component library → product‑specific adapters → final product components. Each layer composes the APIs of the layer below.

Summary

Key principles for composable component design are:

Stable Dependency Principle – keep the API stable for consumers while encapsulating volatility.

Single‑Responsibility Principle – isolate concerns to simplify testing and composition.

Inversion of Control – let consumers inject custom behavior instead of trying to anticipate every scenario.

Flexibility always involves trade‑offs; understanding the purpose of each optimization helps make informed decisions.

Design PatternsfrontendreactComponent CompositionTabs
KooFE Frontend Team
Written by

KooFE Frontend Team

Follow the latest frontend updates

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.