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¶
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