Skip to content

React — Forms

OWNER: floris, floor ALSO_USED_BY: alexander, tobias LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: React 19.x


Overview

GE form stack: React Hook Form + Zod + Server Actions. Shared Zod schema validates on BOTH client and server. No form is ever submitted without server-side validation.


Required Packages

react-hook-form
@hookform/resolvers
zod

1. Shared Schema Pattern

CHECK: Zod schema is in a shared file, NOT duplicated in client and server CHECK: TypeScript type is inferred from schema with z.infer CHECK: schema covers all validation rules — min/max, regex, custom refinements

// schema/user.ts
import { z } from "zod";

export const createUserSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  role: z.enum(["admin", "member", "viewer"]),
  bio: z.string().max(500).optional(),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;

ANTI_PATTERN: defining validation rules in both the form component and the server action FIX: single Zod schema imported by both — one source of truth


2. Client-Side Form

CHECK: form uses useForm with zodResolver CHECK: validation mode is onBlur (NOT onSubmit — allows server action to handle submit) CHECK: error messages render from formState.errors CHECK: submit handler calls Server Action

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createUserSchema, type CreateUserInput } from "@/schema/user";
import { createUser } from "@/app/actions/user";

export function CreateUserForm(): React.ReactElement {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<CreateUserInput>({
    resolver: zodResolver(createUserSchema),
    mode: "onBlur",
  });

  async function onSubmit(data: CreateUserInput) {
    const result = await createUser(data);
    if (result?.errors) {
      for (const [field, message] of Object.entries(result.errors)) {
        setError(field as keyof CreateUserInput, { message });
      }
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register("name")} aria-invalid={!!errors.name} />
        {errors.name && <p role="alert">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register("email")} aria-invalid={!!errors.email} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating..." : "Create User"}
      </button>
    </form>
  );
}

3. Server Action

CHECK: Server Action validates with the SAME Zod schema CHECK: uses safeParse — never throws on invalid input CHECK: returns structured result — { success, data?, errors? } CHECK: uses revalidatePath or revalidateTag after mutation

"use server";

import { createUserSchema } from "@/schema/user";
import { revalidatePath } from "next/cache";

export async function createUser(input: unknown) {
  const parsed = createUserSchema.safeParse(input);

  if (!parsed.success) {
    const fieldErrors: Record<string, string> = {};
    for (const issue of parsed.error.issues) {
      const field = issue.path[0];
      if (field) fieldErrors[String(field)] = issue.message;
    }
    return { success: false, errors: fieldErrors };
  }

  // Database operation
  const user = await db.insert(users).values(parsed.data).returning();

  revalidatePath("/users");
  return { success: true, data: user };
}

ANTI_PATTERN: trusting client data without server-side validation FIX: ALWAYS safeParse in the Server Action — client validation is UX only

ANTI_PATTERN: schema.parse() that throws in a Server Action FIX: use safeParse and return errors — thrown errors crash the action

ANTI_PATTERN: accessing error.errors on ZodError (Zod v4) FIX: use error.issues.errors does not exist in Zod v4


4. useActionState Integration

For progressive enhancement — forms work without JavaScript.

CHECK: use useActionState when form must work with JS disabled CHECK: Server Action receives prevState as first argument CHECK: form has a <SubmitButton> that uses useFormStatus

"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createUser } from "@/app/actions/user";

function SubmitButton(): React.ReactElement {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Creating..." : "Create User"}
    </button>
  );
}

export function CreateUserFormProgressive(): React.ReactElement {
  const [state, formAction, isPending] = useActionState(createUser, null);

  return (
    <form action={formAction}>
      <input name="name" required />
      {state?.errors?.name && <p role="alert">{state.errors.name}</p>}

      <input name="email" type="email" required />
      {state?.errors?.email && <p role="alert">{state.errors.email}</p>}

      <SubmitButton />
    </form>
  );
}

5. Multi-Step Forms

CHECK: step state uses useReducer (NOT multiple useState) CHECK: each step has its own Zod schema CHECK: final submission validates the combined schema CHECK: user can navigate back without losing data

// schema/onboarding.ts
export const step1Schema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

export const step2Schema = z.object({
  company: z.string().min(1),
  role: z.enum(["owner", "employee"]),
});

export const onboardingSchema = step1Schema.merge(step2Schema);
export type OnboardingInput = z.infer<typeof onboardingSchema>;

// components/onboarding-form.tsx
"use client";

export function OnboardingForm(): React.ReactElement {
  const [step, setStep] = useState(0);
  const form = useForm<OnboardingInput>({
    resolver: zodResolver(onboardingSchema),
    mode: "onBlur",
  });

  const schemas = [step1Schema, step2Schema];

  async function handleNext() {
    const fields = Object.keys(schemas[step].shape) as (keyof OnboardingInput)[];
    const valid = await form.trigger(fields);
    if (valid) setStep((s) => s + 1);
  }

  async function handleSubmit(data: OnboardingInput) {
    await submitOnboarding(data);
  }

  return (
    <form onSubmit={form.handleSubmit(handleSubmit)}>
      {step === 0 && <Step1 form={form} />}
      {step === 1 && <Step2 form={form} />}

      <div className="flex gap-2">
        {step > 0 && (
          <button type="button" onClick={() => setStep((s) => s - 1)}>
            Back
          </button>
        )}
        {step < schemas.length - 1 ? (
          <button type="button" onClick={handleNext}>
            Next
          </button>
        ) : (
          <button type="submit">Submit</button>
        )}
      </div>
    </form>
  );
}

ANTI_PATTERN: separate forms per step with local state FIX: single useForm instance across all steps — preserves data on back navigation


6. File Uploads

CHECK: file input uses register with manual onChange for preview CHECK: file validation uses Zod z.instanceof(File) with size/type checks CHECK: large files use presigned URL upload (NOT Server Action body) IF: file is under 4MB THEN: upload via FormData in Server Action IF: file is over 4MB THEN: get presigned URL from server, upload directly to storage

// schema/upload.ts
const MAX_FILE_SIZE = 4 * 1024 * 1024; // 4MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];

export const uploadSchema = z.object({
  file: z
    .instanceof(File)
    .refine((f) => f.size <= MAX_FILE_SIZE, "File must be under 4MB")
    .refine((f) => ACCEPTED_TYPES.includes(f.type), "Only JPEG, PNG, or WebP"),
  alt: z.string().min(1, "Alt text is required for accessibility"),
});

// components/upload-form.tsx
"use client";

export function UploadForm(): React.ReactElement {
  const { register, handleSubmit, watch, formState: { errors } } = useForm({
    resolver: zodResolver(uploadSchema),
  });

  const file = watch("file");
  const preview = file?.[0] ? URL.createObjectURL(file[0]) : null;

  async function onSubmit(data: z.infer<typeof uploadSchema>) {
    const formData = new FormData();
    formData.append("file", data.file);
    formData.append("alt", data.alt);
    await uploadFile(formData);
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="file" accept={ACCEPTED_TYPES.join(",")} {...register("file")} />
      {errors.file && <p role="alert">{errors.file.message}</p>}
      {preview && <img src={preview} alt="Preview" className="mt-2 h-32 object-cover" />}
      <input {...register("alt")} placeholder="Describe the image" />
      <button type="submit">Upload</button>
    </form>
  );
}

ANTI_PATTERN: uploading large files through Server Action body FIX: use presigned URL for files > 4MB — Server Action body has size limits


GE-Specific Conventions

shadcn/ui Form Components

CHECK: forms use shadcn/ui <Form>, <FormField>, <FormItem>, <FormLabel>, <FormMessage> IF: shadcn/ui form primitives are installed THEN: use them — consistent styling and accessibility built in

Error Display

CHECK: field errors render immediately below the field CHECK: server errors (non-field) render at the top of the form CHECK: error messages use role="alert" for screen readers

Loading States

CHECK: submit button shows loading state during submission CHECK: form inputs are NOT disabled during submission (allows correction) CHECK: submit button IS disabled during submission (prevents double submit)


Cross-References

READ_ALSO: wiki/docs/stack/react/index.md READ_ALSO: wiki/docs/stack/react/state-management.md READ_ALSO: wiki/docs/stack/react/pitfalls.md READ_ALSO: wiki/docs/stack/react/checklist.md