2025-09-19 16:34:10 +09:00
|
|
|
/**
|
2025-09-20 13:33:47 +09:00
|
|
|
* Framework-agnostic Zod form utilities for React environments.
|
|
|
|
|
* Provides predictable error and touched state handling.
|
2025-09-19 16:34:10 +09:00
|
|
|
*/
|
|
|
|
|
|
2025-09-20 13:33:47 +09:00
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
|
|
|
import type { FormEvent } from "react";
|
|
|
|
|
import { ZodError, type ZodIssue, type ZodSchema } from "zod";
|
|
|
|
|
|
2025-09-26 18:28:47 +09:00
|
|
|
export type FormErrors<_TValues extends Record<string, unknown>> = Record<
|
2025-09-25 17:42:36 +09:00
|
|
|
string,
|
|
|
|
|
string | undefined
|
|
|
|
|
>;
|
2025-09-26 18:28:47 +09:00
|
|
|
export type FormTouched<_TValues extends Record<string, unknown>> = Record<
|
2025-09-25 17:42:36 +09:00
|
|
|
string,
|
|
|
|
|
boolean | undefined
|
|
|
|
|
>;
|
2025-09-24 18:00:49 +09:00
|
|
|
|
|
|
|
|
export interface ZodFormOptions<TValues extends Record<string, unknown>> {
|
|
|
|
|
schema: ZodSchema<TValues>;
|
|
|
|
|
initialValues: TValues;
|
|
|
|
|
onSubmit?: (data: TValues) => Promise<void> | void;
|
2025-09-20 13:33:47 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
export interface UseZodFormReturn<TValues extends Record<string, unknown>> {
|
|
|
|
|
values: TValues;
|
|
|
|
|
errors: FormErrors<TValues>;
|
|
|
|
|
touched: FormTouched<TValues>;
|
2025-09-20 13:33:47 +09:00
|
|
|
submitError: string | null;
|
|
|
|
|
isSubmitting: boolean;
|
|
|
|
|
isValid: boolean;
|
2025-09-24 18:00:49 +09:00
|
|
|
setValue: <K extends keyof TValues>(field: K, value: TValues[K]) => void;
|
|
|
|
|
setTouched: <K extends keyof TValues>(field: K, touched: boolean) => void;
|
|
|
|
|
setTouchedField: <K extends keyof TValues>(field: K, touched?: boolean) => void;
|
2025-09-20 13:33:47 +09:00
|
|
|
validate: () => boolean;
|
2025-09-24 18:00:49 +09:00
|
|
|
validateField: <K extends keyof TValues>(field: K) => boolean;
|
2025-09-20 13:33:47 +09:00
|
|
|
handleSubmit: (event?: FormEvent) => Promise<void>;
|
|
|
|
|
reset: () => void;
|
2025-09-19 16:34:10 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
function issuesToErrors<TValues extends Record<string, unknown>>(
|
|
|
|
|
issues: ZodIssue[]
|
|
|
|
|
): FormErrors<TValues> {
|
|
|
|
|
const nextErrors: FormErrors<TValues> = {};
|
2025-09-20 13:33:47 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
export function useZodForm<TValues extends Record<string, unknown>>({
|
2025-09-19 16:34:10 +09:00
|
|
|
schema,
|
|
|
|
|
initialValues,
|
2025-09-20 13:33:47 +09:00
|
|
|
onSubmit,
|
2025-09-24 18:00:49 +09:00
|
|
|
}: ZodFormOptions<TValues>): UseZodFormReturn<TValues> {
|
|
|
|
|
const [values, setValues] = useState<TValues>(initialValues);
|
|
|
|
|
const [errors, setErrors] = useState<FormErrors<TValues>>({});
|
|
|
|
|
const [touched, setTouchedState] = useState<FormTouched<TValues>>({});
|
2025-09-20 13:33:47 +09:00
|
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
2025-09-19 16:34:10 +09:00
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
const clearFieldError = useCallback((field: keyof TValues) => {
|
2025-09-20 13:33:47 +09:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
const next: FormErrors<TValues> = { ...prev };
|
2025-09-20 13:33:47 +09:00
|
|
|
delete next[fieldKey];
|
|
|
|
|
Object.keys(next).forEach(key => {
|
|
|
|
|
if (key.startsWith(prefix)) {
|
|
|
|
|
delete next[key];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
const validate = useCallback((): boolean => {
|
2025-09-19 16:34:10 +09:00
|
|
|
try {
|
|
|
|
|
schema.parse(values);
|
|
|
|
|
setErrors({});
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof ZodError) {
|
2025-09-24 18:00:49 +09:00
|
|
|
setErrors(issuesToErrors<TValues>(error.issues));
|
2025-09-19 16:34:10 +09:00
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}, [schema, values]);
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
const validateField = useCallback(
|
|
|
|
|
<K extends keyof TValues>(field: K): boolean => {
|
|
|
|
|
const result = schema.safeParse(values);
|
2025-09-20 13:33:47 +09:00
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
if (result.success) {
|
|
|
|
|
clearFieldError(field);
|
|
|
|
|
setErrors(prev => {
|
|
|
|
|
if (prev._form === undefined) {
|
|
|
|
|
return prev;
|
2025-09-20 13:33:47 +09:00
|
|
|
}
|
2025-09-24 18:00:49 +09:00
|
|
|
const next: FormErrors<TValues> = { ...prev };
|
|
|
|
|
delete next._form;
|
|
|
|
|
return next;
|
2025-09-20 13:33:47 +09:00
|
|
|
});
|
2025-09-24 18:00:49 +09:00
|
|
|
return true;
|
2025-09-20 13:33:47 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
const fieldKey = String(field);
|
|
|
|
|
const relatedIssues = result.error.issues.filter(issue => issue.path[0] === field);
|
2025-09-20 13:33:47 +09:00
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
setErrors(prev => {
|
|
|
|
|
const next: FormErrors<TValues> = { ...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];
|
|
|
|
|
}
|
2025-09-20 13:33:47 +09:00
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2025-09-20 13:33:47 +09:00
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
return next;
|
|
|
|
|
});
|
2025-09-19 16:34:10 +09:00
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
return relatedIssues.length === 0;
|
|
|
|
|
},
|
|
|
|
|
[schema, values, clearFieldError]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const setValue = useCallback(
|
|
|
|
|
<K extends keyof TValues>(field: K, value: TValues[K]): void => {
|
|
|
|
|
setValues(prev => ({ ...prev, [field]: value }));
|
|
|
|
|
clearFieldError(field);
|
|
|
|
|
},
|
|
|
|
|
[clearFieldError]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const setTouched = useCallback(<K extends keyof TValues>(field: K, value: boolean): void => {
|
2025-09-20 13:33:47 +09:00
|
|
|
setTouchedState(prev => ({ ...prev, [String(field)]: value }));
|
|
|
|
|
}, []);
|
2025-09-19 16:34:10 +09:00
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
const setTouchedField = useCallback(
|
|
|
|
|
<K extends keyof TValues>(field: K, value: boolean = true): void => {
|
|
|
|
|
setTouched(field, value);
|
|
|
|
|
void validateField(field);
|
|
|
|
|
},
|
|
|
|
|
[setTouched, validateField]
|
|
|
|
|
);
|
2025-09-20 13:33:47 +09:00
|
|
|
|
|
|
|
|
const handleSubmit = useCallback(
|
2025-09-24 18:00:49 +09:00
|
|
|
async (event?: FormEvent): Promise<void> => {
|
2025-09-20 13:33:47 +09:00
|
|
|
event?.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (!onSubmit) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const valid = validate();
|
|
|
|
|
if (!valid) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true);
|
|
|
|
|
setSubmitError(null);
|
|
|
|
|
setErrors(prev => {
|
|
|
|
|
if (prev._form === undefined) {
|
|
|
|
|
return prev;
|
|
|
|
|
}
|
2025-09-24 18:00:49 +09:00
|
|
|
const next: FormErrors<TValues> = { ...prev };
|
2025-09-20 13:33:47 +09:00
|
|
|
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 }));
|
2025-09-25 15:11:28 +09:00
|
|
|
// Note: Logging should be handled by the consuming application
|
2025-09-20 13:33:47 +09:00
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[validate, onSubmit, values]
|
|
|
|
|
);
|
2025-09-19 16:34:10 +09:00
|
|
|
|
2025-09-24 18:00:49 +09:00
|
|
|
const reset = useCallback((): void => {
|
2025-09-19 16:34:10 +09:00
|
|
|
setValues(initialValues);
|
|
|
|
|
setErrors({});
|
2025-09-20 13:33:47 +09:00
|
|
|
setTouchedState({});
|
|
|
|
|
setSubmitError(null);
|
2025-09-19 16:34:10 +09:00
|
|
|
setIsSubmitting(false);
|
|
|
|
|
}, [initialValues]);
|
|
|
|
|
|
2025-09-20 13:33:47 +09:00
|
|
|
const isValid = useMemo(() => Object.values(errors).every(error => !error), [errors]);
|
|
|
|
|
|
2025-09-19 16:34:10 +09:00
|
|
|
return {
|
|
|
|
|
values,
|
|
|
|
|
errors,
|
2025-09-20 13:33:47 +09:00
|
|
|
touched,
|
|
|
|
|
submitError,
|
2025-09-19 16:34:10 +09:00
|
|
|
isSubmitting,
|
2025-09-20 13:33:47 +09:00
|
|
|
isValid,
|
2025-09-19 16:34:10 +09:00
|
|
|
setValue,
|
2025-09-20 13:33:47 +09:00
|
|
|
setTouched,
|
|
|
|
|
setTouchedField,
|
|
|
|
|
validate,
|
|
|
|
|
validateField,
|
2025-09-19 16:34:10 +09:00
|
|
|
handleSubmit,
|
|
|
|
|
reset,
|
|
|
|
|
};
|
|
|
|
|
}
|