Rreact.wiki
React Snippets

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,
  };
}
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.