typescript 130 lines · 3 tabs

React Signup Form Validation With Zod and Field-Level Errors

Shared by codesnips Jul 2026
3 tabs
import { z } from "zod";

export const signupSchema = z
  .object({
    email: z.string().min(1, "Email is required").email("Enter a valid email"),
    username: z
      .string()
      .min(3, "Username must be at least 3 characters")
      .regex(/^[a-z0-9_]+$/i, "Only letters, numbers and underscores"),
    password: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .regex(/[0-9]/, "Include at least one number"),
    confirmPassword: z.string().min(1, "Please confirm your password"),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"],
  });

export type SignupValues = z.infer<typeof signupSchema>;
3 files · typescript Explain with highlit

This snippet shows how a signup form is validated against a single source of truth — a Zod schema — with errors surfaced next to the individual fields that produced them. The pattern keeps validation rules declarative and reusable, so the same schema can drive both client-side feedback and, eventually, server-side parsing.

In signupSchema.ts, the shape is defined with z.object, and cross-field logic (password confirmation) is expressed with .refine, attaching the error to a specific path so it lands on confirmPassword rather than the form as a whole. The SignupValues type is inferred from the schema with z.infer, which means the form values and the validation rules can never drift apart — changing a field in the schema updates the type everywhere it is consumed.

The useZodForm hook wraps the mechanics of holding values, tracking which fields have been touched, and mapping validation output into a flat errors object keyed by field name. Validation runs through schema.safeParse, which returns a discriminated result instead of throwing; on failure, error.flatten().fieldErrors is reduced into first-message-per-field so each input has at most one string to display. The hook only reveals an error once a field is touched or after a submit attempt, which avoids the jarring experience of showing 'required' messages on a pristine form. handleSubmit marks every field touched and re-validates so nothing slips through if the user clicks submit without blurring inputs.

SignupForm.tsx is a thin presentational layer over the hook. Each Field reads its message from errors, wires aria-invalid and aria-describedby for screen readers, and links the message element by id so assistive tech announces the problem. This separation matters: the component knows nothing about validation rules, and the schema knows nothing about the DOM.

The trade-off is that safeParse runs on every relevant change, which is cheap for a small form but would warrant debouncing for large or expensive schemas. A subtle pitfall handled here is submit-without-blur — without forcing touched on submit, invalid fields could stay silent. Reach for this approach whenever a form needs trustworthy, field-anchored validation without pulling in a heavier form framework.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Semantic HTML Example</title>

Semantic HTML5 elements and accessibility best practices

html html5 semantics
by Alex Chang 2 tabs
ruby
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :account_name, :string
  attribute :email, :string

Shallow Controller, Deep Params: Form Object Pattern

rails activemodel form-object
by codesnips 3 tabs
javascript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {

Promises and async/await patterns for asynchronous JavaScript

javascript promises async-await
by Alex Chang 1 tab
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"

export default class extends Controller {
  connect() {
    // Global shortcuts

Keyboard shortcuts with Stimulus and Mousetrap

stimulus javascript ux
by Jordan Lee 2 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab

Share this code

Here's the card — post it anywhere.

React Signup Form Validation With Zod and Field-Level Errors — share card
Link copied