skip to content

In a shared component library, how should a stateful component let callers either own its state or leave it to the component?

level: middleimportance: should knowfreq 48%

answer

  1. who is the source of truth?
  2. value, default value, change callback
  3. callback fires in both modes
  4. mode fixed at first render
  5. one convention across the library

basics

~20 s

Offer both modes through one convention: a value input makes the component controlled, a default-value input seeds internal state for uncontrolled use, and a change callback fires in either mode. The mode is fixed at first render.

solid answer

~40 s

I give every stateful component — a switch, a tab set, a disclosure's open state — the same trio: a **value** input, a **default value** input and a **change callback**. If the caller passes a value, the component is **controlled**: it renders exactly that value and, on user action, only reports the proposed new value through the callback; nothing changes until the caller passes it back. If not, the component is **uncontrolled**: the default value seeds internal state, later changes to it are ignored, and the callback still fires so callers can observe without owning. The mode is decided at first render, and switching mid-life triggers a development warning. In a driver app, the go-online switch is controlled because the server must approve; a list filter toggle can stay uncontrolled.

go deeper

for a junior

Recall the trio — value, default value, change callback — and which one makes a component controlled.

for a middle

Explain why a controlled component never changes its value itself, why the default value is read only once, and why the callback fires in both modes.

for a senior

Diagnose the classic failures: a mode switch mid-life, a value without a callback, a reset that is ignored. Add the change reason so callers can veto selectively.

for a principal

Make the dual-mode contract a library-wide standard implemented once, so every stateful component behaves identically and the contract can be documented for every platform.

## Two owners for one piece of state A stateful component — a switch, a set of tabs, an accordion panel, a date picker — holds a current value. A shared library cannot know whether the calling screen needs to **own** that value or is happy to **leave it** to the component. Both are common: - **Controlled use**: the caller holds the value and the component displays it. The caller can validate, veto, synchronise with a server, or drive the component from elsewhere. - **Uncontrolled use**: the component keeps its own state. The caller only sets a starting point and, optionally, listens for changes. Less code for the simple case. Offering only one mode pushes the cost onto callers: a controlled-only switch makes every trivial toggle carry state plumbing; an uncontrolled-only switch cannot be vetoed. So well-designed libraries support **both**, with one convention used everywhere. ## The contract | Input | Meaning | When it is read | |---|---|---| | value | the caller owns the state; the component renders exactly this | on every render | | default value | starting value for internal state | first render only | | change callback | reports a user-requested change, with the proposed value | in both modes | The rules that make it predictable: 1. **Presence of a value decides the mode.** If the caller passes a value, the component is controlled; otherwise uncontrolled. 2. **Controlled means the component never changes the value itself.** A tap calls the callback with the proposed value, and the display changes only when the caller passes the new value back. This is what lets a caller refuse a change. 3. **Default value is read once.** Changing it later does not reset the component. Callers who want to reset either switch to controlled use or remount the component. 4. **The callback fires in both modes**, so observing a change never requires taking ownership of it. 5. **The mode is fixed at first render.** A component that starts uncontrolled and later receives a value (or the reverse) is almost always a caller bug, such as a value that was briefly empty; a development-time warning catches it. 6. **One convention across the library.** Every stateful component names and behaves the same way, and the logic lives in one shared internal utility rather than being re-implemented per component. ## A driver app example In a ride-hailing driver app, the **go-online switch** must stay off until the server confirms the driver's documents are valid and the vehicle is approved. The screen uses it **controlled**: the tap calls the change callback, the app sends the request, and the switch shows "online" only once the server says yes. If the request fails, nothing needs undoing because the switch never moved. The same library's **"hide completed trips" toggle** on the earnings list needs no such care. The screen uses it **uncontrolled**, with a default value of off, and listens to the callback to filter the list. Both use the same component and the same three inputs; only the caller's choice differs. ## Refinements that pay off - **Pass the reason with the change.** For an overlay's open state, telling the caller *why* a close was requested (escape key, outside tap, close control) lets it veto one reason — say, an outside tap during a trip confirmation — while allowing the others. - **Warn on a controlled value with no callback.** That combination produces a component that ignores the user, which is only intended for a read-only display; say so in development. - **Apply the pattern beyond form values.** Open/closed, selected tab, expanded panels and the highlighted option of a list are all state that some caller will need to own. ## The same shape on other platforms Frameworks spell this differently — a value plus a callback, a two-way binding, or a caller-owned binding passed into a native control — but the model is the same everywhere: either the caller holds the source of truth, or the component does. A cross-platform design system can therefore document one contract for all its implementations. How a particular framework distinguishes framework-held and element-held field values is that framework's own mechanics.

  • Why does a controlled component that receives a value but no change callback look broken to users?
    The component renders exactly the value it is given and never changes that value itself, so a tap asks for a change nobody receives. The switch or tab stays put. That is only correct for a deliberately read-only display, which is why libraries warn in development when a value arrives without a callback.
  • In an uncontrolled component, how should a caller reset the state after changing its mind about the default value?
    Changing the default value does nothing after the first render, by design. The caller either moves to controlled use and sets the value directly, or remounts the component so the new default is read as a fresh starting point. Libraries document this because 'my new default is ignored' is a common support question.

saying these in an interview costs you the question

  • Changing the default value later should update the displayed state
  • A controlled component should update itself on tap and then notify the caller
  • The change callback only needs to fire in controlled mode
  • Switching between controlled and uncontrolled mid-life is a supported feature
  • Each component can invent its own names for value and change as long as they are documented
  • Supporting both modes means duplicating state logic in every component