Lightweight useForm hook with validation and dirty tracking
Hooksformshooksvalidationform-statelightweight
A minimal, self-contained React hook for form state management with validation, dirty tracking, and submission handling—no external dependencies.
TSX
import { useState, useCallback, useMemo } from 'react';
interface UseFormOptions<T> {
initialValues: T;
validate?: (values: T) => Partial<Record<keyof T, string>>;
}
interface UseFormReturn<T> {
values: T;
errors: Partial<Record<keyof T, string>>;
touched: Partial<Record<keyof T, boolean>>;
isDirty: boolean;
isValid: boolean;
handleChange: <K extends keyof T>(name: K) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
handleBlur: <K extends keyof T>(name: K) => () => void;
reset: () => void;
submit: (onSubmit: (values: T) => Promise<void> | void) => Promise<void>;
}
export function useForm<T extends Record<string, any>>({ initialValues, validate }: UseFormOptions<T>): UseFormReturn<T> {
const [values, setValues] = useState<T>(initialValues);
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({}); // track blur per field
const [submitting, setSubmitting] = useState(false);
const errors = useMemo(() => {
if (!validate) return {};
return validate(values);
}, [values, validate]);
const isDirty = useMemo(() => {
return Object.keys(values).some((key) => {
const k = key as keyof T;
return values[k] !== initialValues[k];
});
}, [values, initialValues]);
const isValid = useMemo(() => {
return Object.keys(errors).length === 0;
}, [errors]);
const handleChange = useCallback(<K extends keyof T>(name: K) => {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const value = e.target.type === 'checkbox' ? (e.target as HTMLInputElement).checked : e.target.value;
setValues(prev => ({ ...prev, [name]: value }));
// auto-touch on change (optional; can be disabled if strict blur-only touch is preferred)
setTouched(prev => ({ ...prev, [name]: true }));
};
}, []);
const handleBlur = useCallback(<K extends keyof T>(name: K) => {
return () => {
setTouched(prev => ({ ...prev, [name]: true }));
};
}, []);
const reset = useCallback(() => {
setValues(initialValues);
setTouched({});
}, [initialValues]);
const submit = useCallback(async (onSubmit: (values: T) => Promise<void> | void) => {
setTouched(Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {} as Partial<Record<keyof T, boolean>>));
if (!isValid) return;
setSubmitting(true);
try {
await onSubmit(values);
} finally {
setSubmitting(false);
}
}, [values, isValid]);
return {
values,
errors,
touched,
isDirty,
isValid,
handleChange,
handleBlur,
reset,
submit,
};
}Use useForm by passing initialValues and an optional validate function that returns field-specific error strings. It tracks touched, isDirty, and isValid automatically. Call handleChange('fieldName') in input onChange, and handleBlur('fieldName') in onBlur (or skip blur if using change-based touch). submit() runs validation, marks all fields as touched, and calls your async submit handler only if valid. Note: touched is updated on change by default (for simplicity); adjust the handleChange logic if you prefer strict blur-only touching. No external deps — just React and TypeScript.