Working with Forms & User InputEntry 2 of 10
Controlled Inputs with One State Object
A controlled input gets its value from React state and updates that state in onChange. React state is the single source of truth for what the input shows.
Notes
Three steps:
- One state object holds every field, instead of one
useStateper input. - One generic change handler updates a field by key, using the input's
name(or an identifier passed in). - Bind each input with
value={values.field}andonChange={handleInputChange}.
Why one object:
- One handler serves every field, so adding a field is one line of markup and one key in the initial state.
- Scales to dynamic forms generated from a config array.
- The whole object can be sent to an API as-is.
The handler must copy the previous state (...prev) and overwrite one key with a computed property name ([name]: value). Use the updater form setValues(prev => …) because the new state depends on the old.
What / Why / When
- What
Inputs whose value always mirrors React state.
- Why
The current value is available on every keystroke for live validation, formatting, or conditional UI.
- When
Validation while typing, inputs that depend on each other, or values shown elsewhere on the page.
- Use case
A contact form with name, email, and message.
Code compare
import { useState, type ChangeEvent, type FormEvent } from "react";type ContactValues = { name: string; email: string; message: string };const INITIAL_VALUES: ContactValues = { name: "", email: "", message: "" };export default function ContactForm() { const [values, setValues] = useState<ContactValues>(INITIAL_VALUES); // One handler for every field: the input's name is the state key function handleInputChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) { const { name, value } = event.target; setValues((prev) => ({ ...prev, [name]: value })); } function handleSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); console.log("Form submitted!", values); // send to an API here } return ( <form onSubmit={handleSubmit}> <input name="name" placeholder="Name" value={values.name} onChange={handleInputChange} /> <input name="email" type="email" placeholder="Email" value={values.email} onChange={handleInputChange} /> <textarea name="message" placeholder="Message" value={values.message} onChange={handleInputChange} /> <button type="submit">Send</button> </form> );}A FormGroup is Angular's "one state object": each FormControl holds a field's value, and formControlName binds it to the input in both directions. There's no change handler to write.
import { Component, inject } from "@angular/core";import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";import { ContactService } from "./contact.service";@Component({ selector: "app-contact-form", imports: [ReactiveFormsModule], template: ` <form [formGroup]="form" (ngSubmit)="onSubmit()"> <input formControlName="name" placeholder="Name" /> <input formControlName="email" type="email" placeholder="Email" /> <textarea formControlName="message" placeholder="Message"></textarea> <input type="checkbox" formControlName="terms" /> <button type="submit">Send</button> </form> `,})export class ContactFormComponent { private contactService = inject(ContactService); form = new FormGroup({ name: new FormControl("", { nonNullable: true }), email: new FormControl("", { nonNullable: true }), message: new FormControl("", { nonNullable: true }), terms: new FormControl(false, { nonNullable: true }), }); onSubmit() { this.contactService.send(this.form.getRawValue()).subscribe(); // API call lives in the service }}// contact.service.ts@Injectable({ providedIn: "root" })export class ContactService { private http = inject(HttpClient); send(values: { name: string; email: string; message: string; terms: boolean }) { return this.http.post(`${environment.apiUrl}/contact`, values); }}To react to changes as they happen, use form.valueChanges (an Observable), or convert it to a signal with toSignal(this.form.valueChanges).
Differences and gotchas
- In React, a controlled input with no
onChangeis read-only. React warns about this. - Never let a controlled
valuebecomeundefined. React treats that as switching from controlled to uncontrolled and warns. Start every field with'', notundefined. - Input values are always strings, even with
type="number". Convert withNumber()(React) or use aFormControl<number>with a number input (Angular converts for you). - Your note says the
nameattribute must match the state key. That's only true for theevent.target.nameversion of the handler; the explicit-identifier version doesn't usename. nonNullable: truemakesreset()return a control to its initial value instead ofnull, and keeps the value typestringinstead ofstring | null.
Quick reference
| React | Angular |
|---|---|
useState({ email: '', … }) | new FormGroup({ email: new FormControl('') }) |
value={values.email} + onChange | formControlName="email" |
[name]: value generic handler | Not needed (two-way binding) |
checked + event.target.checked | formControlName on a checkbox |
Current values: values | form.getRawValue() / form.value |
| React to changes: re-render | valueChanges / toSignal() |