InterviewsVector

Two-Way Binding in React: A Typed useBind Hook (and When to Use React 19 Actions Instead)

Quick answer

React has no built-in two-way binding because it favours one-way data flow for predictability. You can emulate Angular's ngModel with a small hook that returns a value plus a ready-made change handler: const [name, bindName] = useBind(''); then spread <input {...bindName} />. Internally it holds state with useState and returns { value, onChange } (or { checked, onChange } for checkboxes). For real forms, prefer uncontrolled inputs or React 19 Actions (useActionState) — they avoid a re-render on every keystroke.

Short answer: React has no built-in two-way binding like Angular's ngModel because it favours one-way data flow for predictability. You can emulate it with a small hook — const [name, bindName] = useBind(""); <input {...bindName} /> — but for real forms, uncontrolled inputs or React 19 Actions are usually the better choice.

"Build two-way binding in React" is a common interview prompt, and how you answer it says a lot. A junior answer produces a one-line hook that only works for text inputs. A senior answer builds a correct, typed hook and explains why React deliberately avoids two-way binding in the first place — and when not to reach for it.

Why React is one-way by design

Angular's ngModel synchronises the model and the view automatically in both directions. React chose the opposite: state flows down, events flow up. A controlled input renders from state and reports changes through onChange:

const [name, setName] = useState("")
<input value={name} onChange={(e) => setName(e.target.value)} />

This is verbose, but it makes data flow explicit and traceable — there is exactly one source of truth, and every change is a visible event. "Two-way binding" in React just means packaging that value + onChange pair so you don't repeat it.

A correct, typed useBind hook

Most tutorials stop at a text-only hook. A real one has to branch on the input type — checkboxes report checked, number inputs report valueAsNumber:

import { useCallback, useState, type ChangeEvent } from "react"
 
type Bindable = string | number | boolean
type Field = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
 
export function useBind<T extends Bindable>(initial: T) {
  const [value, setValue] = useState<T>(initial)
 
  const onChange = useCallback((e: ChangeEvent<Field>) => {
    const el = e.target
    if (el instanceof HTMLInputElement && el.type === "checkbox") {
      setValue(el.checked as T)
    } else if (el instanceof HTMLInputElement && el.type === "number") {
      setValue(el.valueAsNumber as T)
    } else {
      setValue(el.value as T)
    }
  }, [])
 
  // Checkboxes bind to `checked`; everything else binds to `value`.
  const bind =
    typeof value === "boolean" ? { checked: value, onChange } : { value, onChange }
 
  return [value, bind, setValue] as const
}

Usage stays a single spread per field, and it works across input types:

function ProfileForm() {
  const [name, bindName] = useBind("")
  const [age, bindAge] = useBind(0)
  const [subscribed, bindSubscribed] = useBind(false)
 
  return (
    <form>
      <input type="text" {...bindName} />
      <input type="number" {...bindAge} />
      <input type="checkbox" {...bindSubscribed} />
      <p>{name} · {age} · {subscribed ? "subscribed" : "no"}</p>
    </form>
  )
}

Returning setValue as the third element keeps an escape hatch for resetting or setting the field programmatically.

The React 19 answer most posts miss

Two-way binding makes every keystroke a state update and a re-render. For a live preview that's the point; for a large form it's wasted work. React 19 leans the other way — uncontrolled inputs plus form Actions that read values from FormData only on submit:

import { useActionState } from "react"
 
function ContactForm() {
  const [state, submit, pending] = useActionState(
    async (_prev: { ok: boolean }, formData: FormData) => {
      const name = String(formData.get("name") ?? "")
      await saveContact(name) // server action / async work
      return { ok: true }
    },
    { ok: false }
  )
 
  return (
    <form action={submit}>
      <input name="name" defaultValue="" /> {/* uncontrolled */}
      <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
    </form>
  )
}

No useState per field, no re-render per keystroke, and pending/submitting state comes for free. In an interview, naming this trade-off — "two-way binding is convenient but controlled inputs re-render on every keystroke, so for submission-only forms I'd use uncontrolled inputs or React 19 Actions" — is what separates a senior answer.

When two-way binding is the right call

SituationBetter choice
Live search / character counter / live previewTwo-way binding (useBind) — you need the value every keystroke
Small form, a few fieldsEither; two-way binding is fine
Large form, values only needed at submitUncontrolled inputs or React 19 Actions
Server mutation with pending/error stateReact 19 useActionState + useFormStatus

Common interview traps

  • Text-only hook — forgetting checkboxes (checked) and number inputs (valueAsNumber).
  • Controlled/uncontrolled warning — starting value as undefined then setting it flips the input from uncontrolled to controlled; initialise state properly.
  • Assuming two-way binding is "more advanced" — it's a convenience, and often the wrong default for submission-only forms.
  • Re-render cost — every bound keystroke re-renders; fine for one field, wasteful across a big form.

Interviewer follow-ups

  • "How would you reset the form?" — Expose setValue (as above), or use an uncontrolled form and call requestFormReset / let a successful Action reset it.
  • "How is this different from Vue's v-model?"v-model is compiler sugar over value + input event; our hook is the manual equivalent. Both are one mechanism, not true two-way magic.
  • "Would this cause performance problems?" — On a big form, yes: each keystroke re-renders. That's the motivation for uncontrolled inputs, and often for debouncing the value before doing expensive work with it.

Sources

Key takeaways

  • React is deliberately one-way; two-way binding is a convenience you build, not a primitive you get.
  • A correct useBind hook must branch on input type — checkboxes need `checked`, numbers need valueAsNumber.
  • Controlled two-way binding re-renders on every keystroke; that cost is why React 19 pushes uncontrolled forms + Actions.
  • Reach for two-way binding for small, live-preview inputs; reach for uncontrolled/Actions for real form submission.

Frequently asked questions

Does React have two-way data binding like Angular's ngModel?

No. React uses one-way data flow: state renders the input, and the input reports changes back through an onChange handler you wire up. You can emulate ngModel with a custom hook, but it is a convenience layer over the same one-way mechanism, not a built-in feature.

Are controlled components bad for performance?

Each keystroke updates state and re-renders the component subtree. For small inputs that is negligible. For large forms it can matter, which is why React 19 encourages uncontrolled inputs and form Actions that read values from FormData on submit instead of on every keystroke.

When should I use two-way binding versus an uncontrolled input?

Use two-way binding when you need the value live on every keystroke — a search box, a character counter, a live preview. Use uncontrolled inputs (or React 19 Actions) when you only need the value at submit time, to avoid re-rendering on every keystroke.

By Mohammad Wasi

Software Engineering Leader & Technical Author · Updated August 26, 2026


Related Posts