# How to Create a Reusable Tabs Component in React: Best Practices and Implementation Guide

> Learn to build a reusable tabs component in React using hooks and context API. Follow best practices for optimal accessibility and user experience.

- Repository: [Meta/react](https://github.com/facebook/react)
- Tags: how-to-guide
- Published: 2026-02-18

---

**Build a reusable tabs component in React by combining the `useId` hook for stable ARIA identifiers, context API for state sharing, and proper keyboard navigation handlers, supporting both controlled and uncontrolled modes as implemented in the facebook/react repository.**

Creating a reusable tabs component in React requires balancing accessibility, state management, and composition patterns. The React source code demonstrates these principles through components like [`TabbedWindow.tsx`](https://github.com/facebook/react/blob/main/TabbedWindow.tsx) in the compiler playground, which leverages concurrent features and stable ID generation. This guide shows you how to implement a tabs component that follows the same architectural patterns used in the facebook/react repository.

## Architectural Patterns for React Tabs

### Controlled vs Uncontrolled State Ownership

A robust tabs component must support both **controlled** and **uncontrolled** state patterns. In controlled mode, the parent component passes the `activeTab` value and an `onTabChange` callback, allowing external state management. In uncontrolled mode, the component manages its own state internally using `useState`. This flexibility lets library users either drive the selection logic manually or let the component handle it automatically, matching the pattern used in [`compiler/apps/playground/components/TabbedWindow.tsx`](https://github.com/facebook/react/blob/main/compiler/apps/playground/components/TabbedWindow.tsx).

### Stable ID Generation with useId

Accessibility requires unique identifiers to link tabs with their corresponding panels. Use React's built-in **`useId`** hook to generate stable IDs across server and client renders. According to the implementation in [`packages/react/src/ReactHooks.js`](https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js), this hook guarantees unique identifiers without leaking IDs across renders or hydration mismatches. This is critical for ARIA relationships where `aria-controls` must reference a specific panel ID.

### ARIA Accessibility Requirements

Meeting WAI-ARIA Authoring Practices requires specific attributes:

- **Container**: `role="tablist"` with `aria-orientation="horizontal"`
- **Tab buttons**: `role="tab"`, `aria-selected` (boolean), `aria-controls` (panel ID), and `id` (unique identifier)
- **Panels**: `role="tabpanel"`, `aria-labelledby` (tab ID), and `id` (matching `aria-controls`)

These attributes enable screen readers to announce tab relationships and selection states correctly.

### Keyboard Navigation Implementation

Implement keyboard handlers to support **ArrowLeft**, **ArrowRight**, **Home**, and **End** keys. When focus is on a tab, the right arrow moves focus and selection to the next tab, while the left arrow moves to the previous. Home and End keys jump to the first and last tabs respectively. This implementation mirrors the accessibility patterns found in [`compiler/apps/playground/components/AccordionWindow.tsx`](https://github.com/facebook/react/blob/main/compiler/apps/playground/components/AccordionWindow.tsx).

## Building the Tabs Component

The component architecture uses React Context to share state between the container and sub-components. Create three main exports: `Tabs`, `TabList`, and `TabPanel`. This composition pattern allows consumers to arrange and style elements arbitrarily while maintaining internal logic.

```tsx
// src/components/Tabs.tsx
import React, {useId, useCallback, useEffect, useState, startTransition} from 'react';
import clsx from 'clsx';

type TabsProps = {
  /** Controlled selected key – if omitted the component is uncontrolled */
  selectedKey?: string;
  /** Called when user selects a different tab */
  onSelect?: (key: string) => void;
  /** Children – usually <TabList> and <TabPanel> */
  children: React.ReactNode;
};

export function Tabs({selectedKey, onSelect, children}: TabsProps) {
  // Uncontrolled fallback state
  const [internalKey, setInternalKey] = useState<string | null>(null);
  const isControlled = selectedKey !== undefined && onSelect !== undefined;

  // Resolve the current key
  const activeKey = isControlled ? selectedKey! : internalKey;

  const requestSelect = useCallback(
    (key: string) => {
      // Use startTransition for smoother UI when panel is heavy
      startTransition(() => {
        if (isControlled) {
          onSelect?.(key);
        } else {
          setInternalKey(key);
        }
      });
    },
    [isControlled, onSelect],
  );

  // Provide context to descendants
  const ctx = {activeKey, requestSelect, isControlled};

  return <TabsContext.Provider value={ctx}>{children}</TabsContext.Provider>;
}

/* ------------------------------------------------------------------ */
/* Sub‑components – they read the context created above                */
/* ------------------------------------------------------------------ */

type TabListProps = {children: React.ReactNode};
export function TabList({children}: TabListProps) {
  const id = useId(); // unique base id for ARIA
  return (
    <div role="tablist" aria-orientation="horizontal" className="flex">
      {React.Children.map(children, (child, idx) =>
        React.cloneElement(child as React.ReactElement<any>, {
          tabId: `${id}-tab-${idx}`,
          panelId: `${id}-panel-${idx}`,
        }),
      )}
    </div>
  );
}

type TabProps = {
  /** Unique key for this tab – also used as the value passed to onSelect */
  tabKey: string;
  /** IDs injected by TabList (internal) */
  tabId?: string;
  panelId?: string;
  children: React.ReactNode;
};
export function Tab({tabKey, tabId, panelId, children}: TabProps) {
  const {activeKey, requestSelect} = React.useContext(TabsContext);
  const selected = activeKey === tabKey;

  // Keyboard navigation (←/→/Home/End)
  const onKeyDown = (e: React.KeyboardEvent) => {
    const tabs = Array.from(
      (e.currentTarget.parentElement?.children ?? []) as HTMLButtonElement[],
    );
    const curIdx = tabs.findIndex(t => t.id === tabId);
    let nextIdx = curIdx;

    switch (e.key) {
      case 'ArrowRight':
        nextIdx = (curIdx + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        nextIdx = (curIdx - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        nextIdx = 0;
        break;
      case 'End':
        nextIdx = tabs.length - 1;
        break;
      default:
        return;
    }
    tabs[nextIdx].focus();
    requestSelect(tabs[nextIdx].dataset.key!);
  };

  return (
    <button
      id={tabId}
      role="tab"
      type="button"
      aria-selected={selected}
      aria-controls={panelId}
      data-key={tabKey}
      tabIndex={selected ? 0 : -1}
      onClick={() => requestSelect(tabKey)}
      onKeyDown={onKeyDown}
      className={clsx(
        'px-4 py-2 border-b',
        selected ? 'border-primary text-primary' : 'border-transparent',
      )}
    >
      {children}
    </button>
  );
}

type TabPanelProps = {
  /** Must match the tabKey of the associated Tab */
  tabKey: string;
  /** IDs injected by TabList */
  tabId?: string;
  panelId?: string;
  children: React.ReactNode;
};
export function TabPanel({tabKey, tabId, panelId, children}: TabPanelProps) {
  const {activeKey} = React.useContext(TabsContext);
  const hidden = activeKey !== tabKey;
  return (
    <div
      id={panelId}
      role="tabpanel"
      aria-labelledby={tabId}
      hidden={hidden}
      className="p-4"
    >
      {children}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Context – internal, not exported publicly                         */
/* ------------------------------------------------------------------ */
type TabsContextType = {
  activeKey: string | null;
  requestSelect: (key: string) => void;
  isControlled: boolean;
};
const TabsContext = React.createContext<TabsContextType>({
  activeKey: null,
  requestSelect: () => {},
  isControlled: false,
});

```

## Usage Examples

### Controlled Implementation

In controlled mode, you manage the active state in the parent component, allowing integration with routing or external state management.

```tsx
// src/App.tsx
import React, {useState} from 'react';
import {Tabs, TabList, Tab, TabPanel} from './components/Tabs';

export default function App() {
  const [selected, setSelected] = useState('profile');

  return (
    <Tabs selectedKey={selected} onSelect={setSelected}>
      <TabList>
        <Tab tabKey="profile">Profile</Tab>
        <Tab tabKey="settings">Settings</Tab>
        <Tab tabKey="stats">Stats</Tab>
      </TabList>

      <TabPanel tabKey="profile">
        <h2>Profile</h2>
        <p>Profile content here</p>
      </TabPanel>

      <TabPanel tabKey="settings">
        <h2>Settings</h2>
        <p>Settings content here</p>
      </TabPanel>

      <TabPanel tabKey="stats">
        <h2>Stats</h2>
        <p>Statistics content here</p>
      </TabPanel>
    </Tabs>
  );
}

```

### Uncontrolled Implementation

For simpler use cases, omit the `selectedKey` and `onSelect` props to let the component manage its own state.

```tsx
<Tabs>
  <TabList>
    <Tab tabKey="a">First Tab</Tab>
    <Tab tabKey="b">Second Tab</Tab>
  </TabList>

  <TabPanel tabKey="a">First panel content</TabPanel>
  <TabPanel tabKey="b">Second panel content</TabPanel>
</Tabs>

```

## Performance Optimizations with Concurrent Features

When switching between tabs with heavy content, wrap state updates in **`startTransition`** to prevent UI jank. As implemented in [`compiler/apps/playground/components/TabbedWindow.tsx`](https://github.com/facebook/react/blob/main/compiler/apps/playground/components/TabbedWindow.tsx), this allows React to prioritize urgent updates while deferring the rendering of new tab content. The `requestSelect` function in the example above demonstrates this pattern by wrapping the state change in `startTransition`, ensuring the tab switch feels immediate even if the panel content is complex.

## Summary

- **Use `useId`** from [`packages/react/src/ReactHooks.js`](https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js) to generate stable, SSR-safe identifiers for ARIA attributes.
- **Support both controlled and uncontrolled** modes to provide flexibility for different use cases.
- **Implement keyboard navigation** for ArrowLeft, ArrowRight, Home, and End keys to meet WAI-ARIA standards.
- **Wrap state updates** in `startTransition` when switching tabs to maintain responsive UI, following the pattern in [`TabbedWindow.tsx`](https://github.com/facebook/react/blob/main/TabbedWindow.tsx).
- **Compose with sub-components** (`TabList`, `Tab`, `TabPanel`) to allow flexible styling and layout while maintaining internal logic through Context.

## Frequently Asked Questions

### Should I use controlled or uncontrolled tabs for my application?

**Use controlled tabs** when you need to synchronize the tab state with URL parameters, external state management (Redux, Zustand), or when you need to programmatically change tabs from parent components. **Use uncontrolled tabs** for self-contained UI where the tab state doesn't need to persist or interact with external systems, reducing boilerplate in simple use cases.

### How do I ensure my tabs component is fully accessible?

Implement `role="tablist"` on the container, `role="tab"` on buttons with `aria-selected` and `aria-controls`, and `role="tabpanel"` on content areas with `aria-labelledby`. Use the `useId` hook to generate unique IDs for these relationships, and handle keyboard events for ArrowLeft, ArrowRight, Home, and End keys to enable full keyboard navigation as required by WAI-ARIA Authoring Practices.

### Why does the implementation use `startTransition` when switching tabs?

The `startTransition` API, used in [`compiler/apps/playground/components/TabbedWindow.tsx`](https://github.com/facebook/react/blob/main/compiler/apps/playground/components/TabbedWindow.tsx), marks the tab content update as non-urgent. This prevents heavy panel renders from blocking urgent updates like user input or animations, keeping the interface responsive when switching between content-heavy tabs in concurrent React applications.

### How does `useId` prevent hydration mismatches in server-side rendering?

According to [`packages/react/src/ReactHooks.js`](https://github.com/facebook/react/blob/main/packages/react/src/ReactHooks.js) and verified in [`packages/react-dom/src/__tests__/ReactDOMUseId-test.js`](https://github.com/facebook/react/blob/main/packages/react-dom/src/__tests__/ReactDOMUseId-test.js), `useId` generates identifiers that are stable across server and client renders by using an internal counter that increments consistently regardless of suspense boundaries or component order, ensuring the IDs match during hydration and preventing accessibility attribute mismatches.