skip to content

A shared React component needs to let its parent trigger some behaviour imperatively. How do you decide between forwarding the raw DOM node as a ref, publishing a narrow handle with useImperativeHandle, or keeping it declarative through props?

level: principalimportance: nice to knowfreq 28%

answer

  1. state first, commands only if it must
  2. a moment is not state
  3. what is the component wrapping
  4. one element versus internal structure
  5. exposure is a promise you cannot retract

basics

~20 s

Prefer props and state; reach for an imperative surface only for one-shot actions state cannot express, such as focus or scroll. Forward the raw node for thin DOM wrappers, and use useImperativeHandle to publish a small named API when the component owns internal structure.

solid answer

~50 s

Start declarative. If the behaviour can be expressed as state the parent already owns — open, selected, value — a prop is better, because it survives re-renders, works with server rendering, and cannot desynchronise. Go imperative only for genuine one-shot commands with no state to hold: focus, scroll into view, play, reset a media position. Then choose by what the component *is*. A thin wrapper over a single host element (a styled `input`, a `button`) should forward the ref straight through, because callers legitimately expect the DOM node and the whole DOM API. A composite that owns internal structure should not publish its innards; attach an internal ref to the element that matters and expose a small named object with `useImperativeHandle` — `focus()`, `scrollToRow(id)` — so the internal markup stays refactorable. The cost you are managing is coupling: every exposed node freezes an implementation detail into your public contract.

go deeper

for a junior

Know that most parent-child communication in React goes through props and state, and that refs are the escape hatch for a small set of DOM actions like focusing an input.

for a middle

Be able to say which behaviours genuinely cannot be props — one-shot actions such as focus, scroll or play — and show how a component publishes a small object with useImperativeHandle instead of its node.

for a senior

Argue the case concretely: forward the node for a thin element wrapper, narrow the surface for a composite that owns internal structure, and justify it by what you will be able to refactor afterwards.

for a principal

Own it as an API contract across a shared library: what the imperative surface is allowed to contain, how it is versioned and deprecated, and why every exposed node is a permanent dependency on markup you can no longer change.

## The decision, in order Three options, and they are not equal — try them in this sequence. **1. Declarative props.** Can the parent hold the thing as state and pass it down? `<Dialog open={isOpen} />`, `<Input value={value} />`, `<Row selected={id === selectedId} />`. If yes, stop here. Declarative state is idempotent, survives re-render and remount, is reproducible from a snapshot of your store, and never desynchronises with what is on screen. Every imperative escape hatch trades some of that away. **2. A narrow imperative handle.** Some things are genuinely events, not state: "focus this now", "scroll that into view", "play", "reset the scroll position". Modelling a moment as state produces the familiar `shouldFocus` boolean that has to be flipped back afterwards, and an effect that fires on the wrong render. For these, expose a small object. **3. The raw DOM node.** The maximal surface: the caller gets everything the element can do. ## Choosing between the raw node and a handle Ask what the component is. **Thin wrappers over one host element** — a design-system `Button`, `Input`, `TextArea` — should forward the ref straight to that element. Callers reasonably expect a DOM node from something named after a DOM element, and they will need the long tail: `focus`, `blur`, `scrollIntoView`, `getBoundingClientRect`, positioning a popover against it, feeding it to a third-party integration. Narrowing here is friction with no benefit, because your "internal structure" is one element and it is not going to change. **Composites that own internal structure** — a virtualised table, an editor, a media player, a combobox — should not hand out their internals. The node that matters today (`the scroll container`) may be a different element after the next refactor, and once a consumer holds it, changing your markup is a breaking change you cannot see in your own types. Attach an internal ref and publish intent instead: ```jsx function Editor({ ref }) { const inputRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => inputRef.current.focus(), clear: () => { inputRef.current.value = ''; }, }), []); return <textarea ref={inputRef} />; } ``` Now your contract is two verbs, not the whole DOM API, and the markup underneath is yours to change. ## The tradeoffs to name out loud **Coupling and refactorability.** An exposed node is a permanent, untyped dependency on your internal markup. A named handle is a contract you can version, deprecate and change with intent. This is the argument that decides most real cases. **Testability and reasoning.** Declarative state can be asserted from props; imperative calls have to be observed as effects. A component driven by three imperative commands has an implicit state machine nobody wrote down. **Server rendering and hydration.** An imperative surface only exists on the client. Behaviour that must be correct in the first server-rendered HTML has to be expressible as props. **Concurrency.** Imperative calls happen at a moment; React may render, discard and re-render around them. State reconverges after a re-render; a command that already fired does not. That asymmetry is why one-shot actions are the good fit and ongoing configuration is not. **Discoverability.** A handle you can enumerate is documentation. A raw node tells a consumer nothing about what you intended them to do with it. ## Where teams get it wrong **Exposing a handle out of habit.** `useImperativeHandle` used to be the fashionable way to be a "proper" component, and it produces components whose real API is three methods that each just set internal state — which should have been props. **Narrowing a leaf element.** Wrapping an `input` and exposing only `focus()` means the first consumer who needs `select()` or a bounding rect files a bug against you. For a one-element wrapper, forward the node. **Growing the handle without review.** A handle that reaches nine methods is a component that lost its declarative model. That count is a design signal worth acting on. **Publishing the node "just in case".** Once one consumer takes it, it is load-bearing. Exposure is easy to add later and expensive to remove. ## How to answer State the default (declarative), name the legitimate exception (one-shot commands with no state), and then split the imperative case by component shape: forward the node for thin element wrappers, publish a narrow handle for composites that own structure. Close on the cost you are actually managing — every exposed node is an implementation detail you promised not to change.

  • What is the smell that a component's imperative handle has grown too far?
    Methods that only set internal state — `setOpen`, `setValue`, `select` — rather than performing one-shot actions. Those are props wearing a method signature, and they duplicate a state model the parent should own outright. A handle that has grown past a few genuine commands usually means the declarative API was never designed.
  • If you expose a narrow handle, how do you keep it from going stale?
    Have the handle's methods read from refs or current state at call time rather than closing over values captured when the handle object was created, and give useImperativeHandle an honest dependency list so it rebuilds when what it closes over changes. A method that acts on a value from three renders ago is the classic failure here.
  • Why is an exposed DOM node harder to change later than a named method?
    Because you never see how it is used. Consumers can read any attribute, attach listeners, measure it, or mutate it, and none of that appears in your types or your tests. A named method is a surface you can search for, deprecate and reshape; a node is an open-ended dependency on markup you thought was private.

saying these in an interview costs you the question

  • Treats useImperativeHandle as the default way to build components
  • Exposes the raw node from a large composite for convenience
  • Models a one-shot focus as a boolean prop toggled back after
  • Says imperative APIs are fine because state is slower
  • Adds handle methods that only wrap setState calls

context