Mert Özen Further With Every Line
Frontend React — #10

Forms in React: Controlled Inputs, Form Actions, useActionState, useFormStatus and react-hook-form + zod

Mert Özen Nov 15, 2026 16 min 4 views
Forms in React: Controlled Inputs, Form Actions, useActionState, useFormStatus and react-hook-form + zod

React 19's form actions (useActionState, useFormStatus, useOptimistic) are enough for small forms; for forms with line items and nested data, react-hook-form with a zod schema means less code and fewer renders. On the Bookstore order form we show the controlled version rendering on every key, an action resetting the form after a validation error, useOptimistic reverting by itself on failure, and Problem Details errors mapped to fields with focus moved to them.

Version note: The examples in this post were built and run with React 19.3, react-hook-form 7.92, zod 4.6, @hookform/resolvers 7.0, React Router 8.5, TanStack Query 5.107, TypeScript 7.0, Vite 8.3, Oxlint 1.93 and Node.js 24; API calls went through the Vite dev proxy to a local copy of Bookstore.Api (ASP.NET Core 10), and form behavior was observed with Playwright 1.65 in headless Chromium.

Predict First

In the React client of Bookstore, the online bookstore used as the shared example across this site's series, the checkout page was a single button in the previous post. In this post we build a real order form: name, email, delivery address and an editable quantity field for every book in the cart.

The first version of the form is written with React 19's form actions. The user fills in the address, types four digits into the postal code and clicks "Place order". The action returns a validation error and the message "Postal codes have 5 digits" appears. What is left in the street and city fields the user typed? Second question: in a form that keeps every field in useState, how many times does typing 11 characters into the street field render the component, and how many times with react-hook-form?

A Hand-Written Controlled Form and Where It Gets Hard

In the fifth post we wrote the search box as a controlled input: the value lives in state, the input only shows it. An order form with the same approach:

// Variant: every field is controlled state, validation written by hand
export function CheckoutPage() {
  const { user } = useAuth();
  const { total } = useCart();
  const [name, setName] = useState(user?.name ?? '');
  const [email, setEmail] = useState(user?.email ?? '');
  const [line1, setLine1] = useState('');
  const [city, setCity] = useState('');
  const [postalCode, setPostalCode] = useState('');
  const [errors, setErrors] = useState<Errors>({});

  function validate(): Errors {
    const next: Errors = {};
    if (!name.trim()) next.name = 'Enter your name';
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) next.email = 'Enter a valid email address';
    if (!line1.trim()) next.line1 = 'Enter the street and number';
    if (!city.trim()) next.city = 'Enter the city';
    if (!/^\d{5}$/.test(postalCode)) next.postalCode = 'Postal codes have 5 digits';
    return next;
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const next = validate();
    setErrors(next);
    // ...and the order lines, server errors, focus on the first error, a pending flag, a dirty flag...
  }

Five states for five fields, an error state, hand-written validation. And much is still missing: the line items, server errors, focus on the first invalid field, locking the button while submitting, knowing whether the user has touched a field. Each one means a few more lines of state and conditions. The cost is not only the amount of code. In a production build we measured both versions by typing 11 characters into the street field:

controlled (useState):     typing "Moda Cd. 12" (11 keys): CheckoutPage rendered 11 time(s)
controlled (useState):     submit with errors:          CheckoutPage rendered 1 time(s)
react-hook-form:           typing "Moda Cd. 12" (11 keys): CheckoutPage rendered 0 time(s)
react-hook-form:           submit with errors:          CheckoutPage rendered 2 time(s)

In the controlled form every keystroke rendered the whole form component. In a five-field form you will not notice; in an order table with hundreds of rows, or a form with an expensive preview under every field, you will. react-hook-form did not render at all while typing: the inputs are uncontrolled, the values live in the DOM and the library reads them through refs. It rendered only on submit, when the error state changed.

React 19 Form Actions: useActionState and useFormStatus

React 19 lets you pass a function to a <form>'s action prop. When the form is submitted, React calls this function inside a transition with the form data. useActionState also hands the function the previous state and makes its return value the new state:

// Variant: React 19 form actions, no form library
const contactSchema = z.object({
  name: z.string().trim().min(1, { error: 'Enter your name' }),
  email: z.email({ error: 'Enter a valid email address' }),
  line1: z.string().trim().min(1, { error: 'Enter the street and number' }),
  city: z.string().trim().min(1, { error: 'Enter the city' }),
  postalCode: z.string().regex(/^\d{5}$/, { error: 'Postal codes have 5 digits' }),
});

type FormState = {
  values: Record<string, string>;
  errors: Partial<Record<string, string[]>>;
  message?: string;
  // The action gets the previous state and the submitted FormData; what it returns becomes the new state
  async function submitOrder(_previous: FormState, formData: FormData): Promise<FormState> {
    const values = Object.fromEntries(formData) as Record<string, string>;
    const parsed = contactSchema.safeParse(values);
    if (!parsed.success) {
      return { values, errors: z.flattenError(parsed.error).fieldErrors };
    }
    try {
      const order = await cartApi.placeOrder(cartId!, parsed.data.name, parsed.data.email);
      cartIdStore.set(null);
      queryClient.removeQueries({ queryKey: ['cart'] });
      navigate(`/orders/${order.id}`);
      return { values, errors: {} };
    } catch (error) {
      return { values, errors: {}, message: error instanceof Error ? error.message : String(error) };
    }
  }

  const [state, formAction] = useActionState(submitOrder, {
    values: { name: user?.name ?? '', email: user?.email ?? '' },
    errors: {},
  });
  // After an action React resets the form's uncontrolled inputs; defaultValue puts the submitted text back
  const field = (name: string, label: string) => (
    <p className="field">
      <label htmlFor={name}>{label}</label>
      <input id={name} name={name} defaultValue={state.values[name] ?? ''} aria-invalid={!!state.errors[name]} />
      {state.errors[name] && <span className="error">{state.errors[name].join(' ')}</span>}
    </p>
  );
// useFormStatus reads the state of the <form> it is rendered in: it must be a child component
function SubmitButton() {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>{pending ? 'Placing order…' : 'Place order'}</button>;
}

The inputs have no value or onChange; values are read from FormData. Validation happens inside the action with a zod schema, and errors are written into state. useFormStatus reads the submission state of the <form> it is rendered in; that is why we have to move the button into its own component, since it cannot see a form in the same component. The first and last lines of the run in which we delayed POST /api/orders by one second on submit:

-- submit with postal code "3400"
   inputs: {"Street and number":"Moda Cd. 12","City":"Istanbul","Postal code":"3400"}
   error:  Postal codes have 5 digits
-- remove Emma (DELETE takes 1.5 s)
   at once:  ["1 × Dune"]
   saved:    ["1 × Dune"]
-- remove Dune, the server answers 500
   at once:  []
   after:    ["1 × Dune"]
-- postal code "34710", submit (POST takes 1 s)
   button:  Placing order… | disabled: true
   page:    /orders/1 | Order #1

Looking at the first block, the answer to the first Predict First question seems to be "everything is in place", but that is thanks to the line defaultValue={state.values[name]}. We ran the same form without that line:

-- submit with postal code "3400"
   inputs: {"Street and number":"","City":"","Postal code":""}
   error:  Postal codes have 5 digits

Street, city and postal code were emptied; the user has to type everything again. According to react.dev, when the function passed to action finishes without throwing, React resets the form's uncontrolled fields. Returning a validation error also counts as "finishing without throwing". The documented fix is to keep the submitted values in state and pass them back to defaultValue; React applies the new defaults before resetting. This behavior, useful for clearing a form after a successful save, surprises you the first time you meet it with validation errors.

Instant Feedback with useOptimistic

The same form has a "Remove" button next to every line. Removing is a DELETE request; to hide the line without waiting for the result, we use useOptimistic:

  // Shows the lines without the one being removed until the DELETE finishes
  const [visibleLines, hideLine] = useOptimistic(lines, (current, isbn: string) => current.filter((l) => l.isbn !== isbn));
  function removeLine(isbn: string) {
    startTransition(async () => {
      hideLine(isbn);
      try {
        const saved = await cartApi.remove(cartId!, isbn);
        queryClient.setQueryData(cartKey(saved.id), saved);
      } catch {
        // nothing to roll back: when the transition ends the optimistic value is dropped and the line is back
      }
    });
  }

The middle of the output above is the result of this part: while the DELETE took 1.5 seconds, Emma left the list at once. When we removed Dune the server returned 500; the line disappeared within 100 ms and came back when the transition ended. We wrote no rollback code: the value of useOptimistic only applies while a transition is running, and when the transition ends React goes back to the real value (lines). That is the difference from the TanStack Query optimistic update in the eighth post: there we changed the cache ourselves and wrote the old state back ourselves on error.

The gain of this approach is that it needs no dependency and plugs straight into React's transition model. It uses the same API as Server Functions (fifteenth post); in frameworks, forms that work even before JavaScript loads are written with this API. Its limit is also clear: for per-field live validation, "touched" tracking, nested objects and line item lists, you still write everything by hand.

react-hook-form and zod: Schema, Registration and Field Arrays

npm install react-hook-form@7.92.0 zod@4.6.7 @hookform/resolvers@7.0.6

The schema first. A schema written with zod holds the type of the form values, the validation rules and the error messages in one place:

import * as z from 'zod';

// One schema: the form's types, its validation rules and its error messages
export const checkoutSchema = z.object({
  name: z.string().trim().min(1, { error: 'Enter your name' }).max(200),
  email: z.email({ error: 'Enter a valid email address' }),
  address: z.object({
    line1: z.string().trim().min(1, { error: 'Enter the street and number' }),
    city: z.string().trim().min(1, { error: 'Enter the city' }),
    postalCode: z.string().regex(/^\d{5}$/, { error: 'Postal codes have 5 digits' }),
  }),
  lines: z
    .array(z.object({
      isbn: z.string(),
      title: z.string(),
      unitPrice: z.number(),
      quantity: z.number({ error: 'Enter a number' }).int().min(1, { error: 'At least 1' }).max(99, { error: 'At most 99' }),
    }))
    .min(1, { error: 'Your order has no books' }),
});

export type CheckoutValues = z.infer<typeof checkoutSchema>;

CheckoutValues, derived with z.infer, is not a hand-written interface; when the schema changes, so does the type. In zod 4 the error message is given with the { error: '...' } parameter, and z.email() is now a top-level function. Setting up the form:

export function CheckoutPage() {
  const { user } = useAuth();
  const { cartId, lines } = useCart();
  const queryClient = useQueryClient();
  const navigate = useNavigate();

  const { register, control, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm<CheckoutValues>({
    resolver: zodResolver(checkoutSchema),
    defaultValues: {
      name: user?.name ?? '',
      email: user?.email ?? '',
      address: { line1: '', city: '', postalCode: '' },
      lines: lines.map(({ isbn, title, unitPrice, quantity }) => ({ isbn, title, unitPrice, quantity })),
    },
  });
  // Like a FormArray: one row per order line, each with a stable id for the key
  const { fields, remove } = useFieldArray({ control, name: 'lines' });
  // useWatch subscribes to the lines only; watch() would make the React Compiler skip this component
  const total = useWatch({ control, name: 'lines' }).reduce((sum, line) => sum + line.unitPrice * (line.quantity || 0), 0);

register('address.city') gives the input a name, a ref, an onChange and an onBlur; the dot-separated path maps to the nested object. useFieldArray is the counterpart of FormArray in Angular: it generates an id for every row, and that is used as the key instead of the index; the react-hook-form docs state plainly that using the index as key breaks the rows (the same index-key trap as in the third post). We used useWatch instead of watch() to show the total; the reason was an Oxlint warning on the first version:

> bookstore-react@0.0.0 lint
> oxlint

src/features/checkout/CheckoutPage.tsx:32:17: warning react(incompatible-library): Use of incompatible library help: This API returns functions which cannot be memoized without leading to stale UI. To prevent this, by default React Compiler will skip memoizing this component/hook. However, you may see issues if values from this API are passed to other components/hooks that are memoized

watch() is a function that returns a new value on every call; the React Compiler gives up memoizing such a component. Since we turn the compiler on in the twelfth post, we cleaned up this warning now. The checkout route now comes with a loader; the form has to get the cart lines as default values on its first render:

import { redirect } from 'react-router';
import { cartQuery } from '@/features/cart/cartApi';
import { cartIdStore } from '@/features/cart/cartIdStore';
import { queryClient } from '@/shared/queryClient';

// The form needs the cart lines as default values, so they must be loaded before it renders
export async function checkoutLoader() {
  const cartId = cartIdStore.get();
  const cart = cartId === null ? null : await queryClient.ensureQueryData(cartQuery(cartId));
  if (cart === null || cart.items.length === 0) {
    throw redirect('/cart');
  }
  return null;
}

Submission goes through handleSubmit; the function is called only if the schema passes:

  async function placeOrder(values: CheckoutValues) {
    try {
      // Bring the server cart in line with the edited lines, then order it
      for (const line of lines) {
        const edited = values.lines.find((l) => l.isbn === line.isbn);
        const saved = !edited ? await cartApi.remove(cartId!, line.isbn)
          : edited.quantity !== line.quantity ? await cartApi.setQuantity(cartId!, line.isbn, edited.quantity)
          : null;
        if (saved) {
          queryClient.setQueryData(cartKey(saved.id), saved);
        }
      }
      const order = await cartApi.placeOrder(cartId!, values.name, values.email);
      queryClient.removeQueries({ queryKey: ['cart'] });
      queryClient.invalidateQueries({ queryKey: ['book'] });
      cartIdStore.set(null);
      navigate(`/orders/${order.id}`);
    } catch (error) {
      if (error instanceof ApiError && error.problem) {
        applyServerErrors(error.problem, setError);
      } else {
        setError('root.server', { message: error instanceof Error ? error.message : String(error) });
      }
    }
  }

We removed the router action from the previous post. Hooking react-hook-form up to an action with useSubmit is possible, but since validation and error mapping are already in the form, we kept submission there too; the route keeps only the middleware (sign-in check) and the loader (cart). A note on the address fields: the PlaceOrderRequest in the local API copy only takes the cart, the name and the email; delivery is the Shipping module's job. The address is validated here but not added to the request. The whole flow:

   http: PUT    /api/carts/{cartId}/items/9780441172719 200
   http: PUT    /api/carts/{cartId}/items/9781400079988 200
-- submit with an empty address
   error: Street and number: Enter the street and number
   error: City: Enter the city
   error: Postal code: Postal codes have 5 digits
   focus: Street and number
-- fill the address, postal code "3400"
   error: Postal code: Postal codes have 5 digits
   focus: Postal code
-- postal code "34710", War and Peace quantity 5 (3 in stock)
   total: Total: ₺3,934.00
   http: PUT    /api/carts/{cartId}/items/9781400079988 200
   http: POST   /api/orders 409
   error: form: Not enough stock for: 9781400079988.
   focus: the form error message
-- quantity 3, submit again
   http: PUT    /api/carts/{cartId}/items/9781400079988 200
   http: POST   /api/orders 201
   http: GET    /api/orders/1 200
   page: /orders/1 | Order #1 | Status: Placed | 1 × Dune · ₺489.00 | 3 × War and Peace · ₺2,067.00 | Total: ₺2,556.00

Submitting with an empty address showed three errors at once and focus went to the first invalid field; react-hook-form does this itself through the ref that register provides. After the postal code was fixed and War and Peace's quantity set to 5, the server cart was updated first, the order came back with 409 and a form-level error appeared. With the quantity at 3, the order was created.

Mapping Server Errors to Form Fields

Client validation is for the user experience; the real rule lives on the server, and the two are not always the same. Bookstore.Api returns validation errors in the errors field of Problem Details, keyed by the request's property names. A small function translates them into the form's field names:

import type { FieldPath, UseFormSetError } from 'react-hook-form';
import type { CheckoutValues } from '@/features/checkout/checkoutSchema';
import type { ProblemDetails } from '@/shared/api';

// Bookstore.Api reports errors by request property name (PlaceOrderRequest); the form uses its own names
const fieldFor: Record<string, FieldPath<CheckoutValues>> = {
  CustomerName: 'name',
  CustomerEmail: 'email',
};

export function applyServerErrors(problem: ProblemDetails, setError: UseFormSetError<CheckoutValues>) {
  const unmatched: string[] = [];
  for (const [key, messages] of Object.entries(problem.errors ?? {})) {
    const field = fieldFor[key];
    if (field) {
      setError(field, { type: 'server', message: messages.join(' ') }, { shouldFocus: true });
    } else {
      unmatched.push(...messages);
    }
  }
  // Anything without a field of its own (409 out of stock, unknown keys) goes to the form-level error
  const formMessage = unmatched.length > 0 ? unmatched.join(' ') : problem.errors ? '' : (problem.detail ?? problem.title ?? '');
  if (formMessage) {
    setError('root.server', { type: 'server', message: formMessage });
  }
}

To see the server's rules we temporarily switched off the zod resolver and typed ada@ into the email field:

  const { register, control, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm<CheckoutValues>({
    // resolver: zodResolver(checkoutSchema),  (switched off to see the server rules)
http:  POST /api/orders 400
body:  {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"CustomerEmail":["The CustomerEmail field is not a valid e-mail address."]},"traceId":"00-13d6672a917d45abd47ff4d2b99e0080-7a0f29481f2f52da-00"}
error: Email: The CustomerEmail field is not a valid e-mail address.
aria-invalid on Email: true
focus: Email

The CustomerEmail key landed on the form's email field, the field became aria-invalid and focus moved there. The message is the server's own sentence; replacing it with your own text before showing it, or agreeing on an error code with the server, is better. Errors without a field of their own (the 409 stock error, unmapped keys) go to root.server.

Accessible Error Messages

Every field goes through a small Field component:

// The submit button was focused; move focus to the message so keyboard and screen reader users get it.
// Returning a cleanup keeps React 19 from calling this ref with null (post 07).
function focusOnAttach(node: HTMLParagraphElement) {
  node.focus();
  return () => {};
}

type FieldProps = {
  label: string;
  error: FieldError | undefined;
  children: (props: { id: string; 'aria-invalid': boolean; 'aria-describedby'?: string }) => ReactNode;
};

// Label, input and error message wired together for screen readers
function Field({ label, error, children }: FieldProps) {
  const id = useId();
  return (
    <p className="field">
      <label htmlFor={id}>{label}</label>
      {children({ id, 'aria-invalid': error !== undefined, 'aria-describedby': error ? `${id}-error` : undefined })}
      {error && <span id={`${id}-error`} className="error">{error.message}</span>}
    </p>
  );
}

useId links the label to the input, and aria-describedby links the error message to the input; a screen reader reads both the label and the error when it reaches the field. aria-invalid tells both assistive technology and CSS that the field is invalid. One more problem came up with the form-level error: since the button is disabled while submitting, focus fell from the button to body and a keyboard user did not notice the 409 message. The ref callbacks from the seventh post came in handy: when the message appears, focusOnAttach moves focus to it, and the line "focus: the form error message" in the output above is the result. The details of wording, ordering and visibility of error messages are covered in the Accessibility (a11y) series.

Compared with Angular Reactive Forms and Signal Forms

TopicAngular Reactive FormsAngular Signal Forms (22)React + react-hook-form + zod
Modela FormGroup / FormControl treea signal model and form()useForm and uncontrolled inputs
RulesValidators.required, custom validatorsrules in the schema functiona zod schema, zodResolver
Template bindingformControlName[formField]{...register('name')}
ListsFormArrayan array model and array rules in the schemauseFieldArray
Type safetytyped forms (Angular 14+)from the model typefrom the schema with z.infer
Server errorscontrol.setErrors()async/HTTP validation in the schema, or by handsetError()
Setuppart of the frameworkpart of the frameworktwo separate libraries and a bridge package

The biggest difference is in the last row. In Angular the form infrastructure ships with the framework, and in Angular 22 Signal Forms became stable; we covered it in "Angular 22: Varsayılan OnPush, Stable Signal Forms ve Angular 12 ile Proje Farkı". In React the core only gives you form actions; choosing a library for large forms is your decision. react-hook-form and zod are a common choice but not the only one: TanStack Form, Formik and, for validation, Valibot solve the same problem with different trade-offs.

In Production

Keep client and server rules in sync. The zod schema and the attributes on PlaceOrderRequest live in two separate places; if one changes and the other does not, the user is either blocked for no reason or gets past the form and receives an error from the server. Always leaving a way to map the server's errors to fields shows that gap to the user properly. Generating the schema from the OpenAPI document is another option.

Think about the order of network requests on submit. In our form quantity changes go one by one as PUTs, then the order is placed; an error in between can leave the cart half updated. A single "order the cart with these lines" endpoint on the server would be more robust; API design directly affects the client's work.

Do not send personal data from the form (address, email) to logs, analytics events or the error tracker. react-hook-form's DevTool package or console.log(values) help in development and are a data leak in production.

Common Pitfalls

Forgetting defaultValue in a form action: as we saw above, what the user typed is wiped after a validation error.

Calling useFormStatus in the same component as the form: the hook only sees the form it is rendered in; for a <form> in the same component it always returns "not pending".

Using the index as key in a field array: when a row is removed, React reuses the next row's input and values shift. Use field.id; the react-hook-form docs say this field will be renamed to key in v8.

Leaving number fields as strings: the value of <input type="number"> is still a string. Unless you use register(..., { valueAsNumber: true }) or z.coerce.number() in the schema, zod fails with "expected number".

Forgetting focus management: showing the error message is not enough, keyboard and screen reader users need to notice it. Move focus to the first invalid field or to the form-level message.

A Small Experiment

★☆☆ Add a "Gift note" field to the schema: optional, at most 120 characters. Show the remaining characters with useWatch; measure how many renders happen while typing.

★★☆ Add a "Same as delivery address" checkbox for the billing address. When unchecked, a second address block should appear and be validated only then. Is this more readable in zod with z.discriminatedUnion or with superRefine?

★★★ Write the order form with React 19 actions but keep the zod schema and the server error mapping: the action should turn the FormData into an object matching checkoutSchema (nested address and lines included). Which part is easier than with react-hook-form, and which is harder?

We built the form; the order flow works end to end. Along the way our state spread out: server data in the TanStack Query cache, the user and the cart id in two small external stores, the theme in a Context, the search in the URL. In the next post we put this spread into a decision table, look at Context's performance limit through Zustand's selectors, and discuss when Redux Toolkit is needed.