← Back to question bank
DebuggingSeniorHard#4030 · 35m

Debug compound components under interview pressure

A React screen using compound components passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.

Answer Strategy

The broken pattern in this question is "compound components implemented with React.Children.map + cloneElement". The injected props only reach direct children. Any wrapping div breaks the chain, and callers either learn the rule "no wrappers allowed" or open a bug report. The fix is sharing state through a context that the Root creates; subcomponents read the context regardless of nesting, and callers wrap children however they want.

Locate the boundary by asking "what is the contract between Root and the leaves?". With cloneElement the contract is structural ("you must be a direct child"); with context the contract is semantic ("you must be inside the Root somewhere"). The semantic contract is more flexible and TypeScript-checkable: the leaves throw a clear error if rendered outside Root, no extra prop wiring required.

Adjacent traps: forgetting to memoize the context value (every Root render creates a new object, invalidating consumers wrapped in React.memo), exposing the context directly instead of through a hook (consumers can render-prop bypass the boundary error), and leaking the context across multiple Roots (use a unique context per Root if state must be scoped). The regression test wraps triggers in two divs to assert the cloneElement bug cannot recur.

Regression Fix: Context-Backed Compound Components

The fixed Tabs uses a context for state instead of cloneElement injection; arbitrary wrapping divs no longer break the contract.

// THE BUG: the original Tabs cloned children with React.Children.map and
// injected props. Any wrapping div (like a layout helper) broke the
// injection because cloneElement could not see past the first level. The
// fix shares state through context so any depth of wrapping works and
// callers can compose children freely.

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

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

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

function Root({
  defaultActive,
  children,
}: {
  defaultActive: string;
  children: React.ReactNode;
}) {
  const [active, setActive] = React.useState(defaultActive);
  const value = React.useMemo(() => ({ active, setActive }), [active]);
  return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}

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

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

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

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.

Reproduce
Render the broken cloneElement version with triggers wrapped in a div. Assert the trigger fails to receive the active prop. The context-based version flows state through any nesting.
Patch
Replace cloneElement with createContext + useContext. Memoize the context value. Test wrapping at multiple depths to confirm no regression.
Prevent
Add a code-review heuristic: "if you reach for cloneElement, you wanted context." Pair with a Storybook story that renders the compound with intentional wrappers to keep the contract visible to designers and engineers.
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

describe('Tabs regression', () => {
  it('works through arbitrary wrapping divs', () => {
    render(
      <Tabs.Root defaultActive="a">
        <div className="layout">
          <div className="row">
            <Tabs.Trigger id="a">A</Tabs.Trigger>
            <Tabs.Trigger id="b">B</Tabs.Trigger>
          </div>
        </div>
        <Tabs.Panel id="a">Panel A</Tabs.Panel>
        <Tabs.Panel id="b">Panel B</Tabs.Panel>
      </Tabs.Root>
    );

    // The broken cloneElement version lost the props after the wrapping
    // divs and the trigger never received state. Here state flows
    // through context, depth is irrelevant.
    expect(screen.getByText('Panel A')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('tab', { name: 'B' }));
    expect(screen.getByText('Panel B')).toBeInTheDocument();
  });
});

Interviewer Signal

Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.

Constraints

  • State a hypothesis before changing code.
  • Name what evidence would confirm the bug.
  • Avoid broad rewrites unless the current API cannot express the behavior.

Model Answer Shape

  • Reproduce the failing sequence first.
  • Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
  • Patch the minimal broken boundary and add a regression test.

Tradeoffs

  • A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
  • Adding dependencies can silence lint warnings while still preserving the wrong ownership model.

Edge Cases

  • Double clicks and repeated submissions.
  • Slow network responses arriving out of order.
  • Component remount with stale persisted state.

Testing And Proof

  • Failing interaction sequence.
  • Out-of-order async response.
  • Unmount cleanup.

Follow-Ups

  • What would the code review comment say?
  • What metric or log would show this in production?