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
| Situation | Better choice |
|---|---|
| Live search / character counter / live preview | Two-way binding (useBind) — you need the value every keystroke |
| Small form, a few fields | Either; two-way binding is fine |
| Large form, values only needed at submit | Uncontrolled inputs or React 19 Actions |
| Server mutation with pending/error state | React 19 useActionState + useFormStatus |
Common interview traps
- Text-only hook — forgetting checkboxes (
checked) and number inputs (valueAsNumber). - Controlled/uncontrolled warning — starting
valueasundefinedthen 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 callrequestFormReset/ let a successful Action reset it. - "How is this different from Vue's
v-model?" —v-modelis compiler sugar overvalue+inputevent; 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.
Related guides
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.
Software Engineering Leader & Technical Author · Updated August 26, 2026