Skip to content

Compound Components Interview Questions & Answers

19 questions Updated 2026-06-24 Share:

React compound components interview questions — implicit state sharing, Context-based APIs, flexible composition, and component slot patterns.

Read the in-depth guideReact Compound Components — Complete Interview Guide(opens in new tab)
19 of 19

Compound components are a set of components that work together to form a cohesive UI unit while sharing state implicitly. The parent component owns the state; child sub-components consume it without the consumer having to wire it up manually.

The problem they solve is prop explosion on monolithic components. Consider a <Tabs> component — without the pattern you end up passing tabs, activeTab, onTabChange, renderPanel, and more as props. With compound components the API becomes declarative:

// Monolithic: consumer must juggle every prop
<Tabs
  tabs={[{ label: 'A', content: <div /> }]}
  activeTab={active}
  onTabChange={setActive}
/>

// Compound: consumer composes sub-components freely
<Tabs defaultValue="a">
  <Tabs.List>
    <Tabs.Tab value="a">Tab A</Tabs.Tab>
    <Tabs.Tab value="b">Tab B</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="a">Content A</Tabs.Panel>
  <Tabs.Panel value="b">Content B</Tabs.Panel>
</Tabs>

Rule of thumb: Reach for compound components when multiple tightly related pieces of UI need to share state but the caller should control their layout and composition.

The standard approach is a dedicated Context created inside the compound component module. The parent component provides the shared state; sub-components consume it via useContext. This keeps the shared state invisible to the consumer of the component.

// 1. Create context (not exported — internal implementation detail)
const TabsContext = createContext(null);

// 2. Parent provides state
function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      {children}
    </TabsContext.Provider>
  );
}

// 3. Sub-components consume without any extra props from caller
function TabsTab({ value, children }) {
  const { active, setActive } = useContext(TabsContext);
  return (
    <button
      aria-selected={active === value}
      onClick={() => setActive(value)}
    >
      {children}
    </button>
  );
}

Rule of thumb: Keep the Context object private to the module; export only the sub-components so consumers never reach into internal state directly.

Dot-notation attaches sub-components as static properties on the parent component (Menu.Item, Tabs.Panel). This groups related components under a single import and makes the relationship obvious in JSX.

// Attach sub-components as static properties
function Tabs({ defaultValue, children }) { /* ... */ }

Tabs.List  = function TabsList({ children }) { /* ... */ };
Tabs.Tab   = function TabsTab({ value, children }) { /* ... */ };
Tabs.Panel = function TabsPanel({ value, children }) { /* ... */ };

export default Tabs;

// Consumer imports one name, uses all four components
import Tabs from './Tabs';

<Tabs defaultValue="a">
  <Tabs.List>
    <Tabs.Tab value="a">A</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="a">Content</Tabs.Panel>
</Tabs>

An alternative is named exports (export { Tabs, TabsList, TabsTab, TabsPanel }) which works well with tree-shaking but loses the visual grouping at the import site.

Rule of thumb: Use dot-notation for libraries or shared design system components; named exports are fine for single-app usage where IDE autocomplete fills the gap.

Before Context, a common technique was React.Children.map with React.cloneElement to inject props directly into child elements. The parent inspects its children and injects the shared state as additional props.

function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);

  return (
    <div>
      {React.Children.map(children, child => {
        if (!React.isValidElement(child)) return child;
        // Inject active/setActive into every direct child
        return React.cloneElement(child, { active, setActive });
      })}
    </div>
  );
}

Key drawbacks:

  • Breaks with nestingcloneElement only reaches direct children; wrapping a <Tabs.Tab> in a <div> for layout breaks the injection chain.
  • Type safety is lost — injected props are not reflected in the child component's declared prop types.
  • Performance — clones every child on every render even if state did not change.
  • Fragile — relies on element identity checks that break with HOCs or memoized wrappers.

Rule of thumb: Prefer Context over cloneElement for new compound components; cloneElement is a legacy pattern kept alive only to understand older codebases.

Follow the same controlled/uncontrolled duality used by HTML inputs: accept an optional value + onChange for controlled mode, and a defaultValue for uncontrolled. Inside the component, maintain internal state only when the caller passes no value.

function Tabs({ value, defaultValue, onChange, children }) {
  // Internal state only used in uncontrolled mode
  const [internalActive, setInternalActive] = useState(defaultValue ?? null);

  // Controlled if `value` prop is provided
  const isControlled = value !== undefined;
  const active = isControlled ? value : internalActive;

  function handleChange(next) {
    if (!isControlled) setInternalActive(next); // update internal state
    onChange?.(next);                            // always notify caller
  }

  return (
    <TabsContext.Provider value={{ active, handleChange }}>
      {children}
    </TabsContext.Provider>
  );
}

Rule of thumb: A component is controlled when the caller owns the value prop; never switch between modes at runtime — log a warning if value goes from defined to undefined.

Implicit sharing (via Context) hides the wiring from the consumer. Sub-components reach into Context themselves — the consumer never passes state between siblings.

Explicit sharing means the consumer manually threads state as props, typically using render props or a children function pattern:

// Explicit — caller owns and passes state
<Tabs>
  {({ active, setActive }) => (
    <>
      <TabList active={active} onSelect={setActive} />
      <TabPanel active={active} />
    </>
  )}
</Tabs>

// Implicit — Context handles the wiring
<Tabs defaultValue="a">
  <Tabs.List>
    <Tabs.Tab value="a">A</Tabs.Tab>  {/* reads Context internally */}
  </Tabs.List>
  <Tabs.Panel value="a">Content</Tabs.Panel>
</Tabs>

Implicit sharing produces cleaner JSX and is the hallmark of the compound components pattern. Explicit sharing via render props is more flexible (state is available anywhere in the subtree) but transfers more complexity to the consumer.

Rule of thumb: Use implicit Context sharing for encapsulated design-system components; use render props when the consumer genuinely needs access to internal state for custom logic.

Wrapping the Context in a custom hook adds a guard that throws a descriptive error when a sub-component is used outside its parent. This is far easier to debug than the silent null that useContext returns for a missing provider.

const TabsContext = createContext(null);

// Custom hook with guard
function useTabs() {
  const ctx = useContext(TabsContext);
  if (!ctx) {
    throw new Error(
      'useTabs must be used within a <Tabs> component'
    );
  }
  return ctx;
}

// Sub-components use the guarded hook, not useContext directly
function TabsTab({ value, children }) {
  const { active, handleChange } = useTabs(); // throws if misused
  return (
    <button onClick={() => handleChange(value)}>
      {children}
    </button>
  );
}

Rule of thumb: Always export the custom hook rather than the Context object; this prevents consumers from bypassing the guard and simplifies future refactors.

You need to type three things: the Context shape, the sub-component props, and the parent component augmented with its sub-component statics.

// 1. Type the shared context
interface TabsContextValue {
  active: string;
  handleChange: (value: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);

// 2. Type each sub-component normally
interface TabsTabProps {
  value: string;
  children: React.ReactNode;
}
function TabsTab({ value, children }: TabsTabProps) { /* ... */ }

// 3. Augment the parent type with static properties
interface TabsComponent extends React.FC<TabsProps> {
  List:  React.FC<{ children: React.ReactNode }>;
  Tab:   React.FC<TabsTabProps>;
  Panel: React.FC<{ value: string; children: React.ReactNode }>;
}

const Tabs = function Tabs({ defaultValue, children }: TabsProps) {
  /* ... */
} as TabsComponent;

Tabs.List  = TabsList;
Tabs.Tab   = TabsTab;
Tabs.Panel = TabsPanel;

Rule of thumb: Use a named interface that extends React.FC<Props> to attach sub-component types; this gives consumers full autocomplete on <Tabs. in the editor.

An Accordion is a natural fit: the root tracks which item(s) are expanded; each Accordion.Item provides a nested sub-Context for its own value; Accordion.Trigger toggles expansion; Accordion.Content conditionally renders.

// Root: tracks open items
function Accordion({ type = 'single', children }) {
  const [open, setOpen] = useState(new Set());
  function toggle(value) {
    setOpen(prev => {
      const next = new Set(type === 'single' ? [] : prev);
      prev.has(value) ? next.delete(value) : next.add(value);
      return next;
    });
  }
  return (
    <AccordionContext.Provider value={{ open, toggle }}>
      {children}
    </AccordionContext.Provider>
  );
}

// Item: provides its own value to children via a second context
Accordion.Item = function AccordionItem({ value, children }) {
  return (
    <AccordionItemContext.Provider value={value}>
      {children}
    </AccordionItemContext.Provider>
  );
};

// Trigger reads item context to know which value to toggle
Accordion.Trigger = function AccordionTrigger({ children }) {
  const value  = useContext(AccordionItemContext);
  const { open, toggle } = useAccordion();
  return (
    <button aria-expanded={open.has(value)} onClick={() => toggle(value)}>
      {children}
    </button>
  );
};

Rule of thumb: Nested Context layers are fine — use one for the collection-level state and one for each item's identity; keep both Context objects private.

With a monolithic component the layout is fixed — the component renders its own markup structure. With compound components the consumer controls the layout by placing sub-components anywhere in JSX, interspersing other elements freely.

// Monolithic: layout is baked in, no way to add a badge next to a tab label
<Tabs tabs={[{ label: 'A' }, { label: 'B' }]} />

// Compound: consumer inserts arbitrary content between or inside sub-components
<Tabs defaultValue="a">
  <header className="flex justify-between">
    <Tabs.List>
      <Tabs.Tab value="a">
        Dashboard <Badge count={3} /> {/* freely inserted */}
      </Tabs.Tab>
      <Tabs.Tab value="b">Settings</Tabs.Tab>
    </Tabs.List>
    <UserMenu />  {/* unrelated element, same row */}
  </header>

  <Tabs.Panel value="a"><DashboardContent /></Tabs.Panel>
  <Tabs.Panel value="b"><SettingsContent /></Tabs.Panel>
</Tabs>

Rule of thumb: If different callers need different layouts or want to inject elements between parts of a component, compound components are the right abstraction; if every caller wants the same layout, a simpler prop-driven component is sufficient.

Compound components add indirection and a module-level Context. They are the wrong choice when:

  • The component is simple — a <Button> with a loading state does not warrant a compound API; a single loading boolean prop is clearer.
  • Sub-components are always rendered together — if callers never need to rearrange the parts, the flexibility is overhead with no benefit.
  • Server components — React Server Components cannot use Context, so the pattern requires pushing the compound root to a client boundary.
  • Over-engineering — a team unfamiliar with the pattern will find it harder to maintain than a straightforward props API.
// Overkill — compound component for something trivially prop-driven
<Button.Root>
  <Button.Icon name="save" />
  <Button.Label>Save</Button.Label>
</Button.Root>

// Just use props
<Button icon="save" loading={saving}>Save</Button>

Rule of thumb: Use compound components when callers regularly need layout control over multiple coordinated sub-pieces; prefer simple props when one "shape" serves everyone.

A custom Select needs: the root to track open/value state; a Trigger to open the dropdown; a List to contain options; and Option items that close the list on selection.

const SelectCtx = createContext(null);
function useSelect() {
  const ctx = useContext(SelectCtx);
  if (!ctx) throw new Error('Must be inside <Select>');
  return ctx;
}

function Select({ value, onChange, children }) {
  const [open, setOpen] = useState(false);
  return (
    <SelectCtx.Provider value={{ value, onChange, open, setOpen }}>
      <div className="relative">{children}</div>
    </SelectCtx.Provider>
  );
}

Select.Trigger = function SelectTrigger({ children }) {
  const { value, open, setOpen } = useSelect();
  return (
    <button onClick={() => setOpen(o => !o)} aria-haspopup="listbox">
      {value ?? children}  {/* show selected value or placeholder */}
    </button>
  );
};

Select.Option = function SelectOption({ value, children }) {
  const { onChange, setOpen } = useSelect();
  return (
    <li
      role="option"
      onClick={() => { onChange(value); setOpen(false); }}
    >
      {children}
    </li>
  );
};

Rule of thumb: For accessible custom selects, layer the compound pattern on top of aria-* attributes rather than replacing native <select> unless custom styling genuinely requires it.

A Menu follows the same Context pattern but adds useRef and keyboard event handling to support arrow-key navigation and focus management.

const MenuCtx = createContext(null);

function Menu({ children }) {
  const [open, setOpen]       = useState(false);
  const [focused, setFocused] = useState(0);
  const itemRefs              = useRef([]);

  function handleKeyDown(e) {
    if (e.key === 'ArrowDown') {
      const next = (focused + 1) % itemRefs.current.length;
      setFocused(next);
      itemRefs.current[next]?.focus(); // move DOM focus
    }
    if (e.key === 'Escape') setOpen(false);
  }

  return (
    <MenuCtx.Provider value={{ open, setOpen, focused, setFocused, itemRefs }}>
      <div onKeyDown={handleKeyDown}>{children}</div>
    </MenuCtx.Provider>
  );
}

Menu.Item = function MenuItem({ index, onSelect, children }) {
  const { itemRefs } = useContext(MenuCtx);
  return (
    <button
      ref={el => (itemRefs.current[index] = el)} // register ref
      role="menuitem"
      tabIndex={-1}
      onClick={onSelect}
    >
      {children}
    </button>
  );
};

Rule of thumb: Store an array of item refs in the root Context so the root's keyDown handler can programmatically focus items without each item knowing about its siblings.

The default Context value is used only when a component renders outside any matching Provider — which for compound components means the sub-component is being used incorrectly. There are two schools of thought:

  1. null + runtime guard (recommended): pass null as the default and throw in the custom hook. This surfaces misuse as a clear error rather than a silent undefined.

  2. Typed fallback: provide a full default shape. This prevents errors but silently allows misuse, making bugs hard to trace.

// Option 1: null default + guard (preferred)
const TabsCtx = createContext<TabsContextValue | null>(null);

function useTabs(): TabsContextValue {
  const ctx = useContext(TabsCtx);
  if (ctx === null) {
    throw new Error('<Tabs.Tab> must be rendered inside <Tabs>');
  }
  return ctx;
}

// Option 2: full default (use only for optional composition)
const TabsCtx = createContext<TabsContextValue>({
  active: '',
  handleChange: () => {},  // no-op silently
});

Rule of thumb: Use null with a guard for required compound parents; use a no-op default only when a sub-component is genuinely optional (e.g., a tooltip that renders standalone without a parent wrapper).

Test compound components through their full composed API — render the parent with sub-components just as a consumer would. Avoid importing and rendering sub-components in isolation because they depend on the Context being present.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Tabs from './Tabs';

test('switches active panel on tab click', async () => {
  render(
    <Tabs defaultValue="a">
      <Tabs.List>
        <Tabs.Tab value="a">Tab A</Tabs.Tab>
        <Tabs.Tab value="b">Tab B</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel value="a">Content A</Tabs.Panel>
      <Tabs.Panel value="b">Content B</Tabs.Panel>
    </Tabs>
  );

  // Initial state
  expect(screen.getByText('Content A')).toBeVisible();
  expect(screen.queryByText('Content B')).not.toBeVisible();

  // Interact and assert
  await userEvent.click(screen.getByRole('button', { name: 'Tab B' }));
  expect(screen.getByText('Content B')).toBeVisible();
});

For controlled compound components, test that onChange is called with the correct value and that the displayed state matches the controlled value prop.

Rule of thumb: Test behavior (what the user sees and can do), not implementation (which Context value changed); this keeps tests resilient to internal refactors.

Every consumer of a Context re-renders whenever the entire Context value changes. If the parent passes a new object reference on every render (even if the values are the same), all sub-components re-render unnecessarily.

// Problem: new object reference on every render
function Tabs({ children }) {
  const [active, setActive] = useState('a');
  return (
    <TabsCtx.Provider value={{ active, setActive }}> {/* new ref each time */}
      {children}
    </TabsCtx.Provider>
  );
}

// Fix 1: useMemo to stabilize the context object
function Tabs({ children }) {
  const [active, setActive] = useState('a');
  const ctx = useMemo(() => ({ active, setActive }), [active]);
  return <TabsCtx.Provider value={ctx}>{children}</TabsCtx.Provider>;
}

// Fix 2: split into separate contexts (state vs dispatch)
const TabsStateCtx    = createContext(null); // triggers re-render on change
const TabsDispatchCtx = createContext(null); // stable — setActive never changes

Splitting into a state context and a dispatch context is the most effective approach: components that only call setActive (like Tabs.Tab) subscribe to TabsDispatchCtx and never re-render when the active value changes.

Rule of thumb: Start with a single merged Context; split into state + dispatch only when profiling confirms unnecessary re-renders are a real bottleneck.

React Server Components (RSC) cannot use Context — createContext and useContext are client-only APIs. This means compound components that rely on Context must be client components (marked 'use client').

The practical implication is a client boundary at the compound component root:

// tabs.tsx — must be a client component
'use client';
import { createContext, useContext, useState } from 'react';
// ... full compound component implementation

// page.tsx — server component; Tabs forces a client subtree
import Tabs from './tabs';

export default function Page() {
  return (
    // Everything inside <Tabs> runs on the client
    <Tabs defaultValue="a">
      <Tabs.Tab value="a">A</Tabs.Tab>
      <Tabs.Panel value="a">
        {/* Server components can be passed as children to client components */}
        <ServerDataTable />
      </Tabs.Panel>
    </Tabs>
  );
}

You can still pass server-rendered content as children into a client compound component — RSC allows server components to be children of client components. The server component renders its output and passes it as serialized props.

Rule of thumb: Mark the compound root (and sub-components that use Context) as 'use client'; keep the data-fetching server components as their consumers' children rather than embedding them inside the compound component module.

Both patterns share state with consumers, but they differ in where the consumer interacts with that state.

Render props — the consumer receives state as function arguments and can use it anywhere within the callback, including conditional logic. More explicit and flexible, but produces deeply indented JSX ("callback hell").

Compound components — state is accessed implicitly inside sub-components via Context. Produces flat, declarative JSX that reads like HTML.

// Render props: state is explicit, layout is free-form
<Tabs>
  {({ active, setActive }) => (
    <div className="custom-layout">
      <button onClick={() => setActive('a')}
        style={{ fontWeight: active === 'a' ? 'bold' : 'normal' }}>A</button>
      {active === 'a' && <div>Panel A</div>}
    </div>
  )}
</Tabs>

// Compound: declarative, less flexible but cleaner
<Tabs defaultValue="a">
  <Tabs.Tab value="a">A</Tabs.Tab>
  <Tabs.Panel value="a">Panel A</Tabs.Panel>
</Tabs>

Rule of thumb: Prefer compound components for design-system UI widgets where layout flexibility is bounded; prefer render props when consumers need the raw state to drive arbitrary custom logic.

Compound components should generate and wire ARIA attributes automatically so consumers don't need to add them manually. The Context provides stable IDs and expanded/selected state that sub-components apply to their DOM elements.

function Tabs({ defaultValue, id: baseId = useId(), children }) {
  const [active, setActive] = useState(defaultValue);
  return (
    <TabsCtx.Provider value={{ active, setActive, baseId }}>
      {children}
    </TabsCtx.Provider>
  );
}

Tabs.Tab = function TabsTab({ value, children }) {
  const { active, setActive, baseId } = useTabs();
  return (
    <button
      id={`${baseId}-tab-${value}`}       // stable ID
      role="tab"
      aria-selected={active === value}
      aria-controls={`${baseId}-panel-${value}`} // links to panel
      onClick={() => setActive(value)}
    >
      {children}
    </button>
  );
};

Tabs.Panel = function TabsPanel({ value, children }) {
  const { active, baseId } = useTabs();
  return (
    <div
      id={`${baseId}-panel-${value}`}     // matches aria-controls
      role="tabpanel"
      aria-labelledby={`${baseId}-tab-${value}`}
      hidden={active !== value}
    >
      {children}
    </div>
  );
};

Rule of thumb: Generate IDs with useId() inside the root component and share them via Context so aria-controls/aria-labelledby pairs are always consistent without requiring consumers to manage IDs manually.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel