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'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export type SignupValues = z.infer<typeof signupSchema>;
import { useCallback, useState } from 'react';
import { z, ZodError, ZodType } from 'zod';
type Errors<T> = Partial<Record<keyof T, string>>;
function flattenErrors<T>(error: ZodError): Errors<T> {
const result: Errors<T> = {};
for (const issue of error.issues) {
const key = issue.path[0] as keyof T | undefined;
if (key != null && result[key] === undefined) {
result[key] = issue.message;
}
}
return result;
}
export function useZodForm<T extends Record<string, unknown>>(
schema: ZodType<T>,
initial: T
) {
const [values, setValues] = useState<T>(initial);
const [errors, setErrors] = useState<Errors<T>>({});
const setField = useCallback(
<K extends keyof T>(name: K, value: T[K]) => {
setValues((prev) => ({ ...prev, [name]: value }));
setErrors((prev) => {
if (prev[name] === undefined) return prev;
const next = { ...prev };
delete next[name];
return next;
});
},
[]
);
const validate = useCallback((): T | null => {
const parsed = schema.safeParse(values);
if (parsed.success) {
setErrors({});
return parsed.data;
}
setErrors(flattenErrors<T>(parsed.error));
return null;
}, [schema, values]);
return { values, errors, setField, validate };
}
import React from 'react';
import { signupSchema, SignupValues } from './signupSchema';
import { useZodForm } from './useZodForm';
const EMPTY: SignupValues = {
email: '',
username: '',
password: '',
confirmPassword: '',
};
export function SignupForm({
onSubmit,
}: {
onSubmit: (values: SignupValues) => Promise<void>;
}) {
const { values, errors, setField, validate } = useZodForm(signupSchema, EMPTY);
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const data = validate();
if (data) {
await onSubmit(data);
}
}
function field(name: keyof SignupValues, type = 'text') {
const message = errors[name];
return (
<div className="field">
<label htmlFor={name}>{name}</label>
<input
id={name}
name={name}
type={type}
value={values[name]}
aria-invalid={message ? true : undefined}
aria-describedby={message ? `${name}-error` : undefined}
onChange={(e) => setField(name, e.target.value)}
/>
{message && (
<p id={`${name}-error`} className="error" role="alert">
{message}
</p>
)}
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate>
{field('email', 'email')}
{field('username')}
{field('password', 'password')}
{field('confirmPassword', 'password')}
<button type="submit">Create account</button>
</form>
);
}
This snippet shows how a signup form validates input with Zod and surfaces per-field errors without pulling in a full form library. The whole thing hangs off a single schema, so the runtime validation and the compile-time types stay in sync.
In signupSchema, the shape of the form is declared once with z.object. Each field carries its own message (z.string().email(...), .min(8, ...)), and a cross-field rule is expressed with .refine, whose path: ['confirmPassword'] attaches the failure to a specific field rather than the form root. Exporting SignupValues via z.infer means the component never hand-writes the value type — it is derived from the schema, so adding a field can only happen in one place.
The reusable logic lives in useZodForm hook. It keeps values and an errors map keyed by field name. The important detail is flattenErrors: ZodError exposes error.issues, each with a path array, and the helper walks those into a flat { field: message } record so the UI can look up a message by name. validate runs safeParse, which never throws — it returns a discriminated union with a success boolean, letting the hook branch cleanly. On success it clears errors and returns the parsed data; on failure it stores the flattened map and returns null. The setField callback also clears that field's error on edit, which is the small touch that makes the form feel responsive instead of nagging.
SignupForm wires it together. handleSubmit calls validate, and only proceeds to onSubmit when parsed data comes back non-null, so the network call receives already-validated, correctly typed values. Each input reads its message from errors[name] and sets aria-invalid plus aria-describedby, which keeps the error announced to screen readers, not just shown visually.
The trade-off worth noting is that this validates on submit rather than on every keystroke; per-field live validation would mean calling the schema's field-level parsers on blur. The pattern is a good fit when a project wants one source of truth for both types and rules and prefers a thin hook over a heavier dependency. A pitfall to watch: .refine errors only surface if their path matches a real field, otherwise the message quietly lands on the form root.
Related snips
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
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
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
Share this code
Here's the card — post it anywhere.