/** * Framework-agnostic Zod form utilities for React environments. * Provides predictable error and touched state handling. */ import { useCallback, useMemo, useState } from "react"; import type { FormEvent } from "react"; import { ZodError, type ZodIssue, type ZodSchema } from "zod"; export type FormErrors<_TValues extends Record> = Record< string, string | undefined >; export type FormTouched<_TValues extends Record> = Record< string, boolean | undefined >; export interface ZodFormOptions> { schema: ZodSchema; initialValues: TValues; onSubmit?: (data: TValues) => Promise | void; } export interface UseZodFormReturn> { values: TValues; errors: FormErrors; touched: FormTouched; submitError: string | null; isSubmitting: boolean; isValid: boolean; setValue: (field: K, value: TValues[K]) => void; setTouched: (field: K, touched: boolean) => void; setTouchedField: (field: K, touched?: boolean) => void; validate: () => boolean; validateField: (field: K) => boolean; handleSubmit: (event?: FormEvent) => Promise; reset: () => void; } function issuesToErrors>( issues: ZodIssue[] ): FormErrors { const nextErrors: FormErrors = {}; issues.forEach(issue => { const [first, ...rest] = issue.path; const key = issue.path.join("."); if (typeof first === "string" && nextErrors[first] === undefined) { nextErrors[first] = issue.message; } if (key) { nextErrors[key] = issue.message; if (rest.length > 0) { const topLevelKey = String(first); if (nextErrors[topLevelKey] === undefined) { nextErrors[topLevelKey] = issue.message; } } } else if (nextErrors._form === undefined) { nextErrors._form = issue.message; } }); return nextErrors; } export function useZodForm>({ schema, initialValues, onSubmit, }: ZodFormOptions): UseZodFormReturn { const [values, setValues] = useState(initialValues); const [errors, setErrors] = useState>({}); const [touched, setTouchedState] = useState>({}); const [submitError, setSubmitError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const clearFieldError = useCallback((field: keyof TValues) => { const fieldKey = String(field); setErrors(prev => { const prefix = `${fieldKey}.`; const hasDirectError = prev[fieldKey] !== undefined; const hasNestedError = Object.keys(prev).some(key => key.startsWith(prefix)); if (!hasDirectError && !hasNestedError) { return prev; } const next: FormErrors = { ...prev }; delete next[fieldKey]; Object.keys(next).forEach(key => { if (key.startsWith(prefix)) { delete next[key]; } }); return next; }); }, []); const validate = useCallback((): boolean => { try { schema.parse(values); setErrors({}); return true; } catch (error) { if (error instanceof ZodError) { setErrors(issuesToErrors(error.issues)); } return false; } }, [schema, values]); const validateField = useCallback( (field: K): boolean => { const result = schema.safeParse(values); if (result.success) { clearFieldError(field); setErrors(prev => { if (prev._form === undefined) { return prev; } const next: FormErrors = { ...prev }; delete next._form; return next; }); return true; } const fieldKey = String(field); const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field); setErrors(prev => { const next: FormErrors = { ...prev }; if (relatedIssues.length > 0) { const message = relatedIssues[0]?.message ?? ""; next[fieldKey] = message; relatedIssues.forEach(issue => { const nestedKey = issue.path.join("."); if (nestedKey) { next[nestedKey] = issue.message; } }); } else { delete next[fieldKey]; } const formLevelIssue = result.error.issues.find(issue => issue.path.length === 0); if (formLevelIssue) { next._form = formLevelIssue.message; } else if (relatedIssues.length === 0) { delete next._form; } return next; }); return relatedIssues.length === 0; }, [schema, values, clearFieldError] ); const setValue = useCallback( (field: K, value: TValues[K]): void => { setValues(prev => ({ ...prev, [field]: value })); clearFieldError(field); }, [clearFieldError] ); const setTouched = useCallback((field: K, value: boolean): void => { setTouchedState(prev => ({ ...prev, [String(field)]: value })); }, []); const setTouchedField = useCallback( (field: K, value: boolean = true): void => { setTouched(field, value); void validateField(field); }, [setTouched, validateField] ); const handleSubmit = useCallback( async (event?: FormEvent): Promise => { event?.preventDefault(); if (!onSubmit) { return; } const valid = validate(); if (!valid) { return; } setIsSubmitting(true); setSubmitError(null); setErrors(prev => { if (prev._form === undefined) { return prev; } const next: FormErrors = { ...prev }; delete next._form; return next; }); try { await onSubmit(values); } catch (error) { const message = error instanceof Error ? error.message : String(error); setSubmitError(message); setErrors(prev => ({ ...prev, _form: message })); // Note: Logging should be handled by the consuming application throw error; } finally { setIsSubmitting(false); } }, [validate, onSubmit, values] ); const reset = useCallback((): void => { setValues(initialValues); setErrors({}); setTouchedState({}); setSubmitError(null); setIsSubmitting(false); }, [initialValues]); const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]); return { values, errors, touched, submitError, isSubmitting, isValid, setValue, setTouched, setTouchedField, validate, validateField, handleSubmit, reset, }; }