DevLift
Back to Blog

React Compound Components: Build Flexible UI Kits Where Parent and Children Share Implicit State

Stop adding props for every variation. Compound components let parent and children share state via Context — the pattern powering shadcn/ui, Radix UI, and every serious React UI kit.

Admin
August 2, 20266 min read2 views

React Compound Components: Build Flexible UI Kits Where Parent and Children Share Implicit State

Your team's design system starts with a Tabs component. You pass in an array of tab configs and it renders. Shipped.

A week later, someone needs a notification badge next to one tab label. New prop: badgeCounts. Then marketing wants icons in some tabs. New prop: tabIcons. Then a specific tab needs to be disabled on trial accounts. New prop: disabledTabs. Then someone wants custom rendering per tab based on permissions...

You've built yourself a prop soup component:

// The API you didn't want to write
<Tabs
  tabs={tabItems}
  defaultIndex={0}
  onTabChange={handleTabChange}
  badgeCounts={{ notifications: 5 }}
  disabledTabs={['settings']}
  tabIcons={{ overview: <LayoutDashboard size={16} /> }}
  tabRenderer={(tab, isActive) => <CustomTabLabel tab={tab} active={isActive} />}
  contentClassName="p-6"
  showBorderBottom={false}
/>

Every new design variation demands a new prop. The component has to anticipate all possible futures. Callers can't control layout — they can only poke it through an ever-growing configuration interface.

Compound components solve this at the root.

The Problem With Prop Soup

The issue isn't just API aesthetics. It's that a config-driven component encodes a specific structure. When a caller wants structure that's even slightly different from what you imagined, they're stuck. They either PR your library, fork the component, or write something like tabRenderer={(tab) => <div className="hack hack hack">{tab.label}</div>}.

Compare these two APIs for a Select component:

// Config-driven — caller is trapped
<Select
  options={courseOptions}
  selectedValue={selectedCourse}
  onChange={setSelectedCourse}
  renderOption={(option) => <div className="flex items-center gap-2"><CourseIcon />{option.label}</div>}
  groupBy="category"
  renderGroupLabel={(label) => <GroupHeader>{label}</GroupHeader>}
  noOptionsMessage="No courses found"
  maxHeight={300}
/>
 
// Compound — caller controls structure
<Select value={selectedCourse} onChange={setSelectedCourse}>
  <Select.Trigger>
    <Select.Value placeholder="Pick a course..." />
  </Select.Trigger>
  <Select.Content>
    <Select.Group label="Frontend">
      {frontendCourses.map(course => (
        <Select.Item key={course.id} value={course.id}>
          <CourseIcon slug={course.slug} />
          {course.label}
        </Select.Item>
      ))}
    </Select.Group>
  </Select.Content>
</Select>

The compound version gives the caller complete control over what's rendered inside each slot — without the Select component needing to know about CourseIcon or GroupHeader at all. New requirement? Restructure the JSX. No new prop needed.

The Pattern

The mechanic is simple: the parent manages state, child components consume it through React Context. Neither the parent nor children need to pass state through props — it flows through the Context implicitly.

Here's a complete Tabs implementation:

// src/components/ui/tabs.tsx
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
 
// --- Context ---
 
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (value: string) => void;
}
 
const TabsContext = createContext<TabsContextValue | null>(null);
 
function useTabsContext(): TabsContextValue {
  const ctx = useContext(TabsContext);
  if (!ctx) {
    throw new Error(
      'Tabs sub-components must be rendered inside <Tabs>. ' +
        'Did you accidentally use <Tabs.Trigger> outside a <Tabs>?'
    );
  }
  return ctx;
}
 
// --- Root ---
 
interface TabsProps {
  defaultValue: string;
  value?: string;
  onValueChange?: (value: string) => void;
  children: ReactNode;
  className?: string;
}
 
export function Tabs({ defaultValue, value, onValueChange, children, className }: TabsProps) {
  const [internalValue, setInternalValue] = useState(defaultValue);
 
  // Controlled if `value` is provided, uncontrolled otherwise
  const activeTab = value ?? internalValue;
 
  const setActiveTab = useCallback(
    (next: string) => {
      if (value === undefined) setInternalValue(next);
      onValueChange?.(next);
    },
    [value, onValueChange],
  );
 
  // Memoise the value object: an inline `{ activeTab, setActiveTab }` is a new
  // reference on every Tabs render, which re-renders every sub-component even
  // when the active tab hasn't changed.
  const ctx = useMemo(() => ({ activeTab, setActiveTab }), [activeTab, setActiveTab]);
 
  return (
    <TabsContext value={ctx}>
      <div className={className}>{children}</div>
    </TabsContext>
  );
}
 
// --- Sub-components ---
 
Tabs.List = function TabsList({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return (
    <div role="tablist" className={className}>
      {children}
    </div>
  );
};
 
interface TabsTriggerProps {
  value: string;
  disabled?: boolean;
  children: ReactNode;
  className?: string;
}
 
Tabs.Trigger = function TabsTrigger({ value, disabled, children, className }: TabsTriggerProps) {
  const { activeTab, setActiveTab } = useTabsContext();
  const isActive = activeTab === value;
 
  return (
    <button
      role="tab"
      aria-selected={isActive}
      disabled={disabled}
      onClick={() => setActiveTab(value)}
      data-state={isActive ? 'active' : 'inactive'}
      className={className}
    >
      {children}
    </button>
  );
};
 
interface TabsContentProps {
  value: string;
  children: ReactNode;
  className?: string;
}
 
Tabs.Content = function TabsContent({ value, children, className }: TabsContentProps) {
  const { activeTab } = useTabsContext();
  if (activeTab !== value) return null;
 
  return (
    <div role="tabpanel" className={className}>
      {children}
    </div>
  );
};

Now the usage looks like this:

<Tabs defaultValue="overview">
  <Tabs.List className="border-b">
    <Tabs.Trigger value="overview">
      Overview
      <Badge count={unreadCount} className="ml-1.5" />
    </Tabs.Trigger>
    <Tabs.Trigger value="courses">Courses</Tabs.Trigger>
    <Tabs.Trigger value="settings" disabled>
      Settings
    </Tabs.Trigger>
  </Tabs.List>
 
  <Tabs.Content value="overview" className="py-4">
    <OverviewPanel />
  </Tabs.Content>
  <Tabs.Content value="courses" className="py-4">
    <CoursesPanel />
  </Tabs.Content>
  <Tabs.Content value="settings" className="py-4">
    <SettingsPanel />
  </Tabs.Content>
</Tabs>

Want a badge on a tab? Put it inside Tabs.Trigger — no prop needed. Want to add an icon? Same. Want to change the layout order? Restructure the JSX. The parent doesn't care.

Three things in the implementation worth calling out:

The throwing context guard. useTabsContext throws a descriptive error if a sub-component is used outside the parent. You'll catch this at development time with a useful message instead of a cryptic null pointer.

Controlled and uncontrolled. When value is provided, the caller owns the state. Without it, Tabs manages its own via defaultValue. This is the same contract native HTML inputs use — and it's what lets you use the component both standalone and driven by a form library.

Dot-notation API. Sub-components are attached directly to Tabs. Callers get autocomplete for Tabs. and won't accidentally mix components from different compound families. It also co-locates the full API in one import.

How State Flows

Rendering diagram...

State lives in the parent. Context pushes it down. Sub-components read and write through the context. The caller never sees any of this — they just compose JSX.

Variations

Accordion With Multiple Open Panels

The same structure handles accordions — swap the string state for a Set:

interface AccordionContextValue {
  openItems: Set<string>;
  toggleItem: (value: string) => void;
}
 
const AccordionContext = createContext<AccordionContextValue | null>(null);
 
function useAccordionContext(): AccordionContextValue {
  const ctx = useContext(AccordionContext);
  if (!ctx) throw new Error('Accordion sub-components must be rendered inside <Accordion>.');
  return ctx;
}
 
export function Accordion({ children }: { children: ReactNode }) {
  const [openItems, setOpenItems] = useState<Set<string>>(new Set());
 
  const toggleItem = useCallback((value: string) => {
    setOpenItems(prev => {
      const next = new Set(prev);
      if (next.has(value)) next.delete(value);
      else next.add(value);
      return next;
    });
  }, []);
 
  const ctx = useMemo(() => ({ openItems, toggleItem }), [openItems, toggleItem]);
 
  return (
    <AccordionContext value={ctx}>
      <div>{children}</div>
    </AccordionContext>
  );
}
 
Accordion.Item = function AccordionItem({
  value,
  trigger,
  children,
}: {
  value: string;
  trigger: ReactNode;
  children: ReactNode;
}) {
  const ctx = useAccordionContext(); // not `useContext(...)!` — see the note below
  const isOpen = ctx.openItems.has(value);
 
  return (
    <div>
      <button
        aria-expanded={isOpen}
        onClick={() => ctx.toggleItem(value)}
      >
        {trigger}
      </button>
      {isOpen && <div role="region">{children}</div>}
    </div>
  );
};

One state shape change, completely different behavior. The compound structure is identical.

Note the guard hook rather than useContext(AccordionContext)!. The non-null assertion compiles and then produces exactly the error the pattern is supposed to prevent: Cannot read properties of null (reading 'openItems'), with no clue about which component was misplaced. If you write the guard once per compound family, use it everywhere.

Both providers here render <AccordionContext value={...}> — React 19 lets you use the context object directly as the provider. <AccordionContext.Provider value={...}> is the older spelling and still works; React plans to deprecate it.

displayName for React DevTools

When sub-components are named functions assigned to properties, React DevTools shows them correctly. But if you ever use arrow functions or refactor, set displayName explicitly:

Tabs.List.displayName = 'Tabs.List';
Tabs.Trigger.displayName = 'Tabs.Trigger';
Tabs.Content.displayName = 'Tabs.Content';

Minor thing that pays off the first time you're debugging a prod issue and the component tree just shows Anonymous everywhere.

Accepting Refs

Sub-components wrap DOM elements that callers sometimes need to reach — focus management, scroll restoration, measurements. In React 19 ref is an ordinary prop, so you just declare it:

interface TabsContentProps {
  value: string;
  children: ReactNode;
  className?: string;
  ref?: React.Ref<HTMLDivElement>;
}
 
Tabs.Content = function TabsContent({ value, children, className, ref }: TabsContentProps) {
  const { activeTab } = useTabsContext();
  if (activeTab !== value) return null;
 
  return (
    <div ref={ref} role="tabpanel" className={className}>
      {children}
    </div>
  );
};
Tabs.Content.displayName = 'Tabs.Content';

Radix UI's primitives were written before this existed, so if you go reading their source you'll find React.forwardRef on essentially every one — that's the pre-19 spelling of the same thing, not a different pattern.

Named Exports Alongside Dot Notation

Some teams prefer named exports for more explicit imports and easier per-component refactors. shadcn/ui ships flat named exports (Tabs, TabsList, TabsTrigger, TabsContent) rather than dot notation. Don't expect a tree-shaking win from aliasing dot-notation statics, though — they're still reachable from Tabs, so the bundler keeps them:

// Export both ways — callers can choose
export { Tabs };
export const TabsList = Tabs.List;
export const TabsTrigger = Tabs.Trigger;
export const TabsContent = Tabs.Content;
// Usage with named imports (shadcn/ui style)
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
 
<Tabs defaultValue="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">...</TabsContent>
</Tabs>

You've almost certainly used this pattern already — shadcn/ui and Radix UI are built entirely on compound components.

💡

When you use <Dialog.Root>, <Dialog.Trigger>, <Dialog.Content> from Radix UI, that's exactly this pattern. The difference is Radix owns the Context inside their package — you compose without managing the wiring.

What This Buys You

The API comparison makes the tradeoff concrete:

Config-prop approachCompound component approach
tabRenderer prop for custom labelsPut anything inside <Tabs.Trigger>
disabledTabs={['settings']}<Tabs.Trigger value="settings" disabled>
badgeCounts={{ notifications: 5 }}<Tabs.Trigger>Notifications <Badge /></Tabs.Trigger>
New prop for every layout changeRestructure JSX — parent doesn't care
Caller has no layout controlCaller controls every slot

The compound component API stays the same size as requirements grow. The prop-soup API grows with every new requirement.

When NOT to Use This

Simple components. If your Button needs a variant prop and an onClick, don't reach for compound components. The pattern adds real overhead: a Context, a guard hook, sub-components with their own type definitions. It's only worth it when you genuinely need caller control over composition.

One-off components. A component that lives in one place and has one caller doesn't need a compound API. Build the generalized version when you have two or more callers with meaningfully different structure requirements.

When children aren't really coordinating. If the sub-components don't need to share state with the parent — they're just organizational wrappers — you're adding Context complexity for nothing. Props or simple composition is fine.

Performance-sensitive trees. Every component that reads the Context re-renders when the Context value's identity changes — which, without the useMemo above, is every render of the root, not just every tab switch. For Tabs either way it's fine. But if you're building a compound VirtualList where the context updates on every scroll frame, you'll want to split contexts or memoize aggressively.

⚠️

The implicit Context is the pattern's main footgun. Developers unfamiliar with the codebase will wonder why useTabsContext throws, or why a Tabs.Trigger renders blank when accidentally placed outside a Tabs. Make the error message do the work: "Tabs.Trigger must be rendered inside <Tabs>" is better than "cannot read properties of null". The throwing guard is not optional — it's how the pattern stays debuggable.

The compound component pattern earns its complexity in a design system — anything reused across 10+ call sites with structural variation. For one-off components or simple configs, a well-named prop is still the right tool.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read