Stockio

React Hook Form With Zod: A Typed Forms Guide

Build React forms with React Hook Form and Zod, including transformed values, server validation, field errors, and reliable reset behavior after saving.

By Daniel Kim

React Developer ·

On this page

A dependable form has several representations: strings in browser controls, validated application values, and the record accepted by the server. Bugs appear when those representations are treated as identical. An empty number input is not zero, and a disabled field is not necessarily part of the submitted data.

Define the form contract before controls

Write down the fields the user can edit and the values the server accepts. Keep database-only properties out of the form model. Decide whether an empty string means missing, whether whitespace is trimmed, and whether the server accepts numeric values or numeric strings. Explicit transformations prevent accidental meaning changes.

For an existing endpoint, run a sanitized response through JSON to TypeScript to sketch its record shape. Then create a separate editable input model. A generated response interface is not a validation schema and should not automatically become the shape of every browser control.

React Hook Form manages registration, field state, and submission. Zod describes validation and parsing. Combining them avoids maintaining a second useState object for the same inputs. It does not remove the need for server validation, permission checks, or deliberate error messages.

Keep browser input distinct from parsed output

Browser controls usually produce strings. A schema can transform those strings into domain values, but then the schema's input and output types differ. Reflect that distinction in the useForm generics rather than forcing both sides into a single inferred type.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
name: z.string().trim().min(1, 'Enter a project name'),
seats: z.string()
  .regex(/^[1-9]\d*$/, 'Enter a positive whole number')
  .transform(Number)
  .refine(Number.isSafeInteger, 'Number is too large'),
});

type Input = z.input<typeof schema>;
type Output = z.output<typeof schema>;

export function ProjectForm({
save,
}: {
save: (values: Output) => Promise<void>;
}) {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
  useForm<Input, unknown, Output>({
    resolver: zodResolver(schema),
    defaultValues: { name: '', seats: '1' },
  });

return (
  <form onSubmit={handleSubmit(save)}>
    <label htmlFor="name">Project name</label>
    <input id="name" {...register('name')} />
    <p>{errors.name?.message}</p>
    <label htmlFor="seats">Seats</label>
    <input id="seats" inputMode="numeric" {...register('seats')} />
    <p>{errors.seats?.message}</p>
    <button disabled={isSubmitting}>Create project</button>
  </form>
);
}

This focused example leaves request-error presentation to the supplied save implementation. In production, connect field messages through aria-describedby and set aria-invalid when a field is invalid. See the accessible interfaces guide for broader interaction requirements.

The digit check deliberately runs before Number conversion. Blind coercion can turn an empty string into zero, which may pass an unintended rule. Input and output types should make that transformation visible to reviewers. Check the installed versions of React Hook Form, the resolver, and Zod together when adopting schema examples.

Register fields without creating competing owners

For standard inputs, use register and let the library manage their values. Adding a value prop backed by separate component state creates two owners unless you intentionally integrate a controlled field. Derived hints, such as a character count, can subscribe through the library rather than mirror the full form.

Custom date pickers and rich select components often need Controller or useController. Their value and change contracts may differ from a native event. Adapt those contracts explicitly, especially when a control returns an object but the form should store only an identifier.

The React Hook Form documentation explains registration and subscriptions. Prefer narrow subscriptions such as a field-level watch when one preview needs one value. Watching the entire form at the top level can cause broader rendering than the interface requires.

Dynamic fields need an identity strategy. For field arrays, use the stable identifier provided by the field-array API as the React key, not the visible index. Removing one attendee should not move another attendee's typed value into the wrong row. Decide whether unmounted conditional fields retain their values or are unregistered.

Validate again at the server boundary

Client validation improves feedback, but it is bypassable. The server must parse submitted data, enforce authorization, and apply domain rules such as seat limits for the current plan. A valid integer is not necessarily an allowed purchase quantity.

Share schemas only when the browser and server genuinely share the same rules and the shared module contains no server-only dependencies. Otherwise share small primitives and keep environment-specific validation separate. The Zod documentation describes parsing and error handling without prescribing your application's authorization policy.

Map server field errors only to known editable fields. Put non-field failures in a form-level message rather than inventing a field name. Error ownership matters: a taken project slug belongs to that field, while a lost session may require reauthentication and preservation of the user's draft.

Avoid exposing raw backend messages directly. They may contain internal implementation details or be unsuitable for users. Translate known error codes into stable copy, log appropriate diagnostics securely, and keep entered values available for correction. A failed submit should not reset the form simply because the request finished.

Reset with a deliberate editing policy

defaultValues describe initialization; they are not a universal instruction to overwrite the form whenever remote data changes. When loading a different record, call reset with that record's editable fields at an intentional point. For a background refetch of the same record, decide whether dirty local edits should survive.

After a successful save, reset to the canonical server response if the server normalizes values. That updates the baseline for dirty tracking. Canonical values prevent a trimmed title or normalized phone number from appearing perpetually unsaved.

Test typing, clearing, pasting, keyboard submission, and rapid double submission. Check empty numeric input, large numbers, Unicode names, conditional fields, and a server rejection after client success. The MDN form guide is useful for native behavior that abstractions can obscure.

For large forms, measure before splitting everything into memoized components. Good subscriptions and clear field ownership often matter more than wrapper count. Keep related examples in the React collection, and record your form's reset and server-error policies near its implementation so later contributors preserve them.

Separate availability hints from authoritative validation

A project-slug availability check is useful feedback, but it introduces another asynchronous lifecycle. Debounce requests so every keystroke does not contact the server. Cancel obsolete requests where supported, and ignore a response unless it still corresponds to the current field value. Otherwise a slow response for an earlier slug can overwrite feedback for the latest one.

Distinguish checking, available, unavailable, and check-failed states. A network failure should not claim that the slug is available, nor should it permanently prevent a submission that the server could validate successfully. Keep this advisory state separate from the field's actual value and the final submission result.

Availability can change between checking and saving. Enforce uniqueness in the authoritative datastore and translate a conflicting save into a field error while preserving the draft. Test two browser sessions submitting the same slug simultaneously: one may succeed while the other receives a correction message. The earlier green indicator is a hint, not a reservation or a guarantee.

Key takeaways

  • Model browser strings and validated domain values separately when schemas transform input.
  • Give each field one owner, and integrate controlled widgets through an explicit adapter.
  • Validate on the server, preserve drafts after failures, and reset only according to a clear editing policy.
Featured sponsor

Reach designers & developers at work

Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.

Become a sponsor

Keep reading

Related articles

More in JavaScript & Frameworks
JavaScript & Frameworks

10 Best TypeScript Learning Resources

Choose a practical path into TypeScript with official guides, books, interactive courses, and exercises for JavaScript learners, plus tips for practising types against realistic data.

Includes: JSON to TSSofia MarchettiSofia Marchetti
JavaScript & Frameworks

10 Best Free React Courses (Learn React for Free)

Choose a free React learning path that matches your experience, from official lessons and guided exercises to full projects. See what each resource teaches and what you should know first.

Includes: JSON to TSSofia MarchettiSofia Marchetti
JavaScript & Frameworks

10 Best React Frameworks

Compare ten React frameworks for websites, web apps, and full-stack projects. Learn which fits your rendering needs, hosting plans, team experience, and appetite for configuration.

Includes: JSON to TSDaniel KimDaniel Kim
JavaScript & Frameworks

Vue vs React: How to Choose for a New Project

Compare Vue 3 and React through state models, team workflows, full-stack options, and a practical prototype exercise that turns framework preferences into a defensible project decision.

Includes: JSON to TSSofia MarchettiSofia Marchetti
JavaScript & Frameworks

TypeScript Type Guards and Type Narrowing

Use control-flow analysis, discriminated unions, and honest custom type guards to turn uncertain values into dependable types without unsafe assertions.

Includes: JSON to TSSofia MarchettiSofia Marchetti
JavaScript & Frameworks

Typed Component Manifest for Your Design System

Build a typed component manifest for library catalogs, ownership checks, and documentation links, while separating generated TypeScript types from runtime validation.

Includes: JSON to TSDaniel KimDaniel Kim