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.

react.devuseState
angular.devFormGroupFormControl

Notes

Three steps:

  1. One state object holds every field, instead of one useState per input.
  2. One generic change handler updates a field by key, using the input's name (or an identifier passed in).
  3. Bind each input with value={values.field} and onChange={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

Reactcontact-form.tsx
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.

Angularcontact-form.component.ts
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  }}
Angularcontact.service.ts
// 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 onChange is read-only. React warns about this.
  • Never let a controlled value become undefined. React treats that as switching from controlled to uncontrolled and warns. Start every field with '', not undefined.
  • Input values are always strings, even with type="number". Convert with Number() (React) or use a FormControl<number> with a number input (Angular converts for you).
  • Your note says the name attribute must match the state key. That's only true for the event.target.name version of the handler; the explicit-identifier version doesn't use name.
  • nonNullable: true makes reset() return a control to its initial value instead of null, and keeps the value type string instead of string | null.

Quick reference

ReactAngular
useState({ email: '', … })new FormGroup({ email: new FormControl('') })
value={values.email} + onChangeformControlName="email"
[name]: value generic handlerNot needed (two-way binding)
checked + event.target.checkedformControlName on a checkbox
Current values: valuesform.getRawValue() / form.value
React to changes: re-rendervalueChanges / toSignal()