typescriptintermediate

React Form Validation Hook

A type-safe form validation hook with field-level errors, dirty tracking, and submit handling.

typescript
'use client';
import { useState, useCallback, FormEvent } from 'react';

type Errors<T> = Partial<Record<keyof T, string>>;
type Validators<T> = Partial<Record<keyof T, (value: T[keyof T], values: T) => string | undefined>>;

function useForm<T extends Record<string, unknown>>({
  initialValues,
  validators,
  onSubmit,
}: {
  initialValues: T;
  validators?: Validators<T>;
  onSubmit: (values: T) => void | Promise<void>;
}) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Errors<T>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
  const [submitting, setSubmitting] = useState(false);

  const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
    setValues((prev) => ({ ...prev, [field]: value }));
    if (validators?.[field]) {
      const error = validators[field]!(value, { ...values, [field]: value });
      setErrors((prev) => ({ ...prev, [field]: error }));
    }
  }, [validators, values]);

  const setFieldTouched = useCallback((field: keyof T) => {
    setTouched((prev) => ({ ...prev, [field]: true }));
  }, []);

  const validate = useCallback((): boolean => {
    if (!validators) return true;
    const newErrors: Errors<T> = {};
    let valid = true;
    for (const [field, validator] of Object.entries(validators)) {
      const error = (validator as Function)(values[field as keyof T], values);
      if (error) {
        newErrors[field as keyof T] = error;
        valid = false;
      }
    }
    setErrors(newErrors);
    return valid;
  }, [validators, values]);

  const handleSubmit = useCallback(async (e: FormEvent) => {
    e.preventDefault();
    if (!validate()) return;
    setSubmitting(true);
    try {
      await onSubmit(values);
    } finally {
      setSubmitting(false);
    }
  }, [validate, onSubmit, values]);

  return { values, errors, touched, submitting, setValue, setFieldTouched, handleSubmit };
}

// Usage
function SignupForm() {
  const form = useForm({
    initialValues: { email: '', password: '' },
    validators: {
      email: (v) => (!String(v).includes('@') ? 'Invalid email' : undefined),
      password: (v) => (String(v).length < 8 ? 'Min 8 chars' : undefined),
    },
    onSubmit: async (vals) => console.log('Submit:', vals),
  });

  return (
    <form onSubmit={form.handleSubmit}>
      <input value={String(form.values.email)}
        onChange={(e) => form.setValue('email', e.target.value)}
        onBlur={() => form.setFieldTouched('email')} />
      {form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
      <button type="submit" disabled={form.submitting}>Submit</button>
    </form>
  );
}

Use Cases

  • Type-safe form handling without libraries
  • Field-level validation with error display
  • Reusable form logic across components

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.