← Back to question bank
React AppSeniorHard#4029 · 55m

Implement compound components in a product component

Use compound components to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

Compound components are the question that exposes whether you can design a flexible component API. The pattern: a Root component owns the state and exposes it through context; named subcomponents (List, Trigger, Panel) consume the context and render the right slot. Callers compose them in any structure they want — wrapped in extra divs, conditionally rendered, even reordered — without a single prop drilled from Root to leaf.

The senior detail is exposing children as a namespace (Tabs.Root, Tabs.Trigger) rather than separately importable components. This signals "these belong together" at the API surface and makes refactors cheap because the contract is the named export, not the file path. The context value must be memoized so that consumers wrapped in React.memo do not invalidate when an unrelated parent re-renders.

Adjacent traps: cloning children with React.Children.map to inject props (fragile, breaks any wrapping), using a singleton context outside React (loses provider scoping when nested), and forgetting to throw a clear error when a leaf is used outside Root (silent runtime breakage). The reference component throws by name so a developer can locate the misuse without a stack-trace dive.

Reference Implementation: Tabs Compound With Context Sharing

A Tabs primitive built as Root + List + Trigger + Panel sharing state through a memoized context, with explicit boundary errors on misuse.

// Compound components share state through a context that the Root creates.
// The win is API ergonomics: <Tabs.Root><Tabs.List><Tabs.Trigger/>...
// composes naturally without prop drilling and without the Root needing
// to know which children exist or how they nest.

type TabsContextValue = {
  active: string;
  setActive: (id: string) => void;
};

const TabsContext = React.createContext<TabsContextValue | null>(null);

function useTabsContext(componentName: string) {
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error(componentName + ' must be used inside Tabs.Root');
  return ctx;
}

function Root({
  defaultActive,
  children,
}: {
  defaultActive: string;
  children: React.ReactNode;
}) {
  const [active, setActive] = React.useState(defaultActive);
  // Memoize so that consumers using React.memo do not re-render when an
  // unrelated parent renders. The identity is stable across renders that
  // do not change active.
  const value = React.useMemo(() => ({ active, setActive }), [active]);
  return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}

function List({ children }: { children: React.ReactNode }) {
  return (
    <div role="tablist" style={{ display: 'flex', gap: 8 }}>
      {children}
    </div>
  );
}

function Trigger({ id, children }: { id: string; children: React.ReactNode }) {
  const { active, setActive } = useTabsContext('Tabs.Trigger');
  return (
    <button
      role="tab"
      aria-selected={active= id}
      aria-controls={'panel-' + id}
      onClick={()=> setActive(id)}
    >
      {children}
    </button>
  );
}

function Panel({ id, children }: { id: string; children: React.ReactNode }) {
  const { active } = useTabsContext('Tabs.Panel');
  if (active !== id) return null;
  return (
    <div role="tabpanel" id={'panel-' + id}>
      {children}
    </div>
  );
}

export const Tabs = { Root, List, Trigger, Panel };

Executable UI Sandbox

UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.

Browser sandbox
HTML, CSS, DOM events, focus, and keyboard behavior
Preview running...
Loading editor...

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Composition flexibility
Render the components in different nestings — wrap List in a div, render Panels in a different order, conditionally render some Triggers — and assert behavior is unchanged. The point of the pattern is structural freedom for the caller.
Boundary errors
Render a Trigger outside Root and assert it throws with the component name in the message. This catches the most common misuse before it reaches a confusing runtime null-context bug.
A11y wiring
Assert role="tablist" on List, role="tab" with aria-selected on Trigger, role="tabpanel" with id matching Trigger’s aria-controls. The compound API hides the wiring; tests guarantee the contract.
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

describe('Tabs compound', () => {
  it('shares state through context, no prop drilling', () => {
    render(
      <Tabs.Root defaultActive="overview">
        <Tabs.List>
          <Tabs.Trigger id="overview">Overview</Tabs.Trigger>
          <Tabs.Trigger id="details">Details</Tabs.Trigger>
        </Tabs.List>
        <Tabs.Panel id="overview">Overview content</Tabs.Panel>
        <Tabs.Panel id="details">Details content</Tabs.Panel>
      </Tabs.Root>
    );

    // Active panel is shown; inactive is unmounted.
    expect(screen.getByText('Overview content')).toBeInTheDocument();
    expect(screen.queryByText('Details content')).toBeNull();

    fireEvent.click(screen.getByRole('tab', { name: 'Details' }));
    expect(screen.queryByText('Overview content')).toBeNull();
    expect(screen.getByText('Details content')).toBeInTheDocument();
  });

  it('throws if a child is used outside Tabs.Root', () => {
    expect(() => render(<Tabs.Trigger id="x">x</Tabs.Trigger>)).toThrow();
  });
});

Interviewer Signal

Shows whether you can explain React behavior while building maintainable product UI.

Constraints

  • Name what is render-derived and what is stored state.
  • Keep side effects owned by events or effects deliberately.
  • Provide a testable boundary for business logic.

Model Answer Shape

  • Start from the user workflow and state ownership.
  • Move pure decisions out of JSX when the branch logic grows.
  • Use React primitives to express ownership, not to hide unclear state.

Tradeoffs

  • Colocating state improves clarity until sibling coordination becomes the real problem.
  • Memoization helps only after render cost or identity churn is measured.

Edge Cases

  • Strict Mode re-running development effects.
  • Stale closures after async work resolves.
  • Unmounts and route changes during in-flight operations.

Testing And Proof

  • Reducer or pure function test for core state transitions.
  • Interaction test for the user workflow.
  • Regression case for stale or repeated async behavior.

Follow-Ups

  • How would this change with server rendering?
  • Where would you place this state in a larger app?