skip to content

You are building a reusable React Tabs component. How do you let it manage the selected tab itself by default while still allowing a parent to own that state, and what does the component do differently in each mode?

level: middleimportance: should knowfreq 46%

answer

  1. let the caller decide who owns it
  2. value present means parent owns it
  3. undefined check, not truthiness
  4. never update internal state when controlled
  5. the mode must not flip mid-life

basics

~20 s

Support both modes: seed internal state from a defaultValue prop, and treat the component as controlled whenever a value prop is supplied. Controlled, it renders the parent's value and only reports changes; uncontrolled, it updates its own state.

solid answer

~50 s

I give the component both a `defaultValue` and a `value` prop, following the convention React's own form inputs use. `value !== undefined` means the parent owns the state, so the component renders the parent's value and its click handler does nothing but call `onChange` — it must not update internal state, or the two would diverge. When `value` is undefined the component is uncontrolled: it keeps its own `useState` seeded from `defaultValue`, updates it on click, and still calls `onChange` so the parent can observe without owning. The rendered value is a single expression, `isControlled ? value : internal`, so there is exactly one source of truth in each mode. The mode is decided by whether `value` was supplied on the first render and must not flip afterwards — React only warns about that switch for built-in form elements, not for your component.

code

javascript · 27 lines
javascript
import { useState } from 'react';

export function Tabs({ value, defaultValue, onChange, tabs }) {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(defaultValue ?? tabs[0].id);
  const selected = isControlled ? value : internal;

  function select(next) {
    if (!isControlled) setInternal(next);
    onChange?.(next);
  }

  return (
    <div role="tablist">
      {tabs.map((tab) => (
        <button
          key={tab.id}
          role="tab"
          aria-selected={tab.id === selected}
          onClick={() => select(tab.id)}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

go deeper

for a junior

Know the vocabulary: controlled means the parent supplies the value and receives the change callback; uncontrolled means the component keeps the value itself, seeded from a default prop. Be able to say which one a snippet is using.

for a middle

Write the implementation: the undefined check that picks the mode, the single derived selected expression, skipping the internal update when controlled, and firing onChange in both modes. Explain why each of those four lines matters.

for a senior

Show the failure modes you have hit — a truthiness check swallowing an empty selection, a component that flips modes when its value prop becomes undefined, internal state shadowing the parent's — and how you guard them, since React itself only warns for built-in form elements.

for a principal

Own the API convention across a component library: whether widgets are dual-mode by default, how the prop names and callback shapes stay uniform, how the two modes are encoded in types so they cannot be mixed, and what that consistency buys when ownership of a value has to move later.

## The question behind the question This is a state-placement question wearing a component-API costume. "Who owns the selected tab?" has two legitimate answers — the component, or its parent — and a reusable component should not force the caller to accept one of them. The dual-mode API is how you postpone the placement decision to the call site. ## The two modes **Uncontrolled**: the component owns the state. The caller writes `<Tabs defaultValue="overview" />` and never thinks about it again. The initial value comes from `defaultValue`; the component updates itself on interaction. **Controlled**: the parent owns the state. The caller writes `<Tabs value={tab} onChange={setTab} />`, usually because something else on the screen depends on the selection — a URL segment, a sibling panel, a form. The component becomes a pure function of `value` plus a reporter of intent. The naming convention is worth copying rather than inventing: React's built-in DOM inputs already use `value` for the controlled prop and `defaultValue` for the uncontrolled seed, so callers recognise the contract instantly. ## The implementation ```jsx function Tabs({ value, defaultValue, onChange, children }) { const isControlled = value !== undefined; const [internal, setInternal] = useState(defaultValue); const selected = isControlled ? value : internal; function select(next) { if (!isControlled) setInternal(next); onChange?.(next); } // render children with `selected` and `select` } ``` Four details carry the whole design: 1. **`value !== undefined` is the mode test.** Not `value !== null` and not truthiness — `''`, `0` and `false` are all legitimate controlled values, and a truthiness check silently drops the component into uncontrolled mode for them. 2. **`selected` is computed, never stored twice.** The rendered value is derived in one place, so there is exactly one source of truth per mode. 3. **Skip the internal `setInternal` when controlled.** If the component updated its own state as well, that state would shadow or race the parent's value, which is the source of "my controlled tabs jump back" bugs. 4. **Call `onChange` in both modes.** Uncontrolled does not mean silent — a parent may want to log or react to the selection without owning it. ## Freeze the mode A component must not switch modes during its lifetime. `<Tabs value={maybeUndefined} />` starts controlled when the value is loaded and flips to uncontrolled the moment the caller passes `undefined`, at which point the component falls back to whatever stale internal state it kept, and the UI jumps. React warns about this for built-in form elements only; nothing in React warns for your own component, so the guard is yours to write — capture `isControlled` from the first render in a ref and warn in development if it changes, or document the contract clearly and let types enforce it. In TypeScript the mode can be encoded as a union so the two shapes cannot be mixed: ```tsx type TabsProps = | { value: string; onChange: (next: string) => void; defaultValue?: never } | { value?: never; onChange?: (next: string) => void; defaultValue?: string }; ``` ## Why not just always be controlled? Always-controlled is a defensible choice for an internal design system: fewer branches, one story for every consumer. The cost is ceremony — every caller of a self-contained widget must declare a `useState` for state nobody else reads, which is over-lifting by policy. Always-uncontrolled is the opposite failure: the day a URL or a sibling needs the selection, callers start reaching for hacks (remounting via `key`, imperative refs) because the component gives them no way in. Supporting both is the reason nearly every widget library converged on this API. ## The parent's side When the caller controls the component, the state has been lifted into the caller, and everything about lifting applies: the caller places it at the nearest common ancestor of the tabs and whatever else needs it, and the callback is the only way the selection changes. The dual-mode API is really a way of saying "lift this if and when you need to" without forking the component. ## What interviewers listen for The `undefined` check rather than truthiness, the skipped internal update in controlled mode, calling `onChange` in both modes, and the awareness that flipping modes mid-life is a bug your own component must guard because React will not do it for you.

  • Why test `value !== undefined` rather than checking whether value is truthy?
    Because `''`, `0` and `false` are legitimate controlled values. A truthiness test would read them as "no value supplied" and silently drop the component into uncontrolled mode, so the parent's empty selection would be ignored and internal state would take over. `undefined` is the only value that genuinely means "the caller did not pass this prop".
  • What breaks if the component updates its internal state while it is controlled?
    You get two sources of truth for one value. The internal update renders one selection; the parent's next render pushes a different one, so the UI flickers or snaps back, and whether it settles correctly depends on whether the parent actually stored what the callback reported. In controlled mode the click handler must only report intent.
  • Should `onChange` still fire when the component is uncontrolled?
    Yes. Owning the state and observing it are different things — a parent may want to log the selection, sync analytics, or update a heading without taking over. Firing the callback in both modes keeps the component's outward behaviour identical, so a caller can switch from uncontrolled to controlled without rewiring anything else.

saying these in an interview costs you the question

  • Checks `if (value)` instead of comparing against undefined
  • Updates internal state even when the value prop is supplied
  • Copies the value prop into state so both modes share one setter
  • Assumes React warns when a custom component changes mode
  • Says a component must be either controlled or uncontrolled, never both

context