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.
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><select id="cars" name="cars">
<option value="audi">Audi</option>
<option value="mercedes">Mercedes</option>
</select></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
propsare 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
<select>, 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
TabsListcomponent manages the list of tabs, handles focus, and provides a
selectTabByIndexhelper:
<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
Tabscomponent 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.
KooFE Frontend Team
Follow the latest frontend updates
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.