RoadmapDay 59 / 80
React (Web)Month 3 · Week 12

Day 59: Build: Custom Form Validation Engine

Design a small, schema-driven validation engine — the core idea behind libraries like Zod/Yup + React Hook Form, built from first principles.

Mark this day complete

Study

Concepts

Separate the schema, the validation runner, and the UI

A robust validation engine separates three concerns: a declarative SCHEMA describing rules per field (required, minLength, pattern, a custom validator function), a RUNNER that takes the schema + current values and produces an errors object, and the UI layer that decides WHEN to run it (on blur, on change after first blur, on submit) — conflating these makes the system rigid and hard to test in isolation.

Validating on every keystroke from the start feels responsive but is usually the wrong default UX — validating a field for the first time only on blur (or submit), and THEN switching to on-change for that specific field once it has an error, is the pattern most production forms converge on: it avoids yelling "required!" at a field the user has not even reached yet.

See It

Visualizations

Visualization

Per-field validation timing state machine

pristine

never touched — no validation run, no error shown

touched (on blur)

first validation run — error shown if invalid

validating on change

once touched+invalid, re-validate every keystroke for fast feedback

valid

error cleared, back to change-triggered re-checks only if edited again

Build It

Code Examples

A tiny schema-driven validation engine

js
const validators = {
  required: (msg = 'Required') => (value) => (value?.trim() ? null : msg),
  minLength: (n, msg) => (value) => (value.length >= n ? null : msg ?? `Must be at least ${n} characters`),
  pattern: (regex, msg) => (value) => (regex.test(value) ? null : msg),
  custom: (fn) => fn, // fn: (value, allValues) => string | null
};

function runValidation(schema, values) {
  const errors = {};
  for (const field in schema) {
    for (const rule of schema[field]) {
      const error = rule(values[field], values);
      if (error) { errors[field] = error; break; } // first failing rule wins per field
    }
  }
  return errors;
}

const signupSchema = {
  email: [validators.required('Email is required'), validators.pattern(/.+@.+\..+/, 'Invalid email')],
  password: [validators.required(), validators.minLength(8)],
  confirmPassword: [
    validators.custom((value, all) => (value === all.password ? null : 'Passwords must match')),
  ],
};

A useForm hook consuming the engine, with correct blur/change timing

jsx
function useForm(schema, initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  function handleChange(field, value) {
    setValues((v) => ({ ...v, [field]: value }));
    if (touched[field]) { // only re-validate live once the field has been touched once
      setErrors((e) => ({ ...e, ...runValidation({ [field]: schema[field] }, { ...values, [field]: value }) }));
    }
  }

  function handleBlur(field) {
    setTouched((t) => ({ ...t, [field]: true }));
    setErrors((e) => ({ ...e, ...runValidation({ [field]: schema[field] }, values) }));
  }

  function handleSubmit(onValid) {
    return (e) => {
      e.preventDefault();
      const allErrors = runValidation(schema, values);
      setErrors(allErrors);
      setTouched(Object.fromEntries(Object.keys(schema).map((k) => [k, true])));
      if (Object.keys(allErrors).length === 0) onValid(values);
    };
  }

  return { values, errors, touched, handleChange, handleBlur, handleSubmit };
}

Remember

Key Takeaways

  • Separate schema (rules), runner (pure function producing errors), and UI (timing decisions) — each becomes independently testable.
  • Validate on blur first, then switch to on-change ONLY for fields that already have an error — the standard, least-annoying UX pattern.
  • A rule list per field with "first failure wins" keeps error messages focused instead of showing every violated rule at once.
  • This is the same conceptual shape as Zod/Yup + React Hook Form — building it once makes those libraries' APIs feel obvious rather than magic.
  • Cross-field validation (confirmPassword matching password) needs access to ALL values, not just the field being validated — design the runner signature for this from the start.

Do It

Practice

  1. 1Add async validation support (e.g. "check if username is taken") with a pending state shown in the UI while it resolves.
  2. 2Add array-field support (a dynamic list of "team members" each with their own nested validation).
  3. 3Compare your engine's API against Zod's schema API and note two things Zod handles that yours currently does not.