DNA📄 HTMLForms & Validation Patterns
ðŸĢHatchlingHTMLReactFormsAccessibility

Forms & Validation Patterns

Forms are deceptively complex. Native validation, controlled inputs, accessible error handling — this is where frontend craft shows.

Forms & Validation Patterns

Forms account for the majority of user interaction bugs. The difference between a junior and senior approach: seniors make forms accessible, validated, and resilient by default, not as an afterthought.

Native HTML Validation

The browser provides a validation API that most engineers ignore in favor of JavaScript:

<form novalidate>
  <label for="email">Email</label>
  <input
    id="email"
    type="email"
    required
    pattern="[^@]+@[^@]+\.[^@]+"
    minlength="5"
    maxlength="254"
  />
  <label for="age">Age</label>
  <input id="age" type="number" min="18" max="120" required />
</form>
AttributeValidates
requiredField is not empty
type="email"Basic email format
patternRegex match
min / maxNumeric range
minlength / maxlengthString length

Constraint Validation API

const input = document.querySelector('#email');
input.validity.valid;        // boolean
input.validity.typeMismatch; // true if type="email" fails
input.validity.patternMismatch;
input.validationMessage;     // browser's default error string
input.setCustomValidity('Custom error message');

Use novalidate on the <form> to suppress browser tooltips but still use the API programmatically. This gives you control over error presentation while keeping native validation logic.

Controlled Forms in React

React's controlled input pattern ties input state to component state:

function LoginForm() {
  const [form, setForm] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState<Record<string, string>>({});
 
  const validate = () => {
    const next: Record<string, string> = {};
    if (!form.email.includes('@')) next.email = 'Invalid email address';
    if (form.password.length < 8) next.password = 'Must be at least 8 characters';
    setErrors(next);
    return Object.keys(next).length === 0;
  };
 
  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (validate()) submitToAPI(form);
  };
 
  return (
    <form onSubmit={handleSubmit} noValidate>
      <Field
        label="Email"
        value={form.email}
        error={errors.email}
        onChange={(v) => setForm((f) => ({ ...f, email: v }))}
      />
      <Field
        label="Password"
        type="password"
        value={form.password}
        error={errors.password}
        onChange={(v) => setForm((f) => ({ ...f, password: v }))}
      />
      <button type="submit">Log in</button>
    </form>
  );
}

Accessible Field Component

function Field({ label, error, ...props }: FieldProps) {
  const id = useId();
  const errorId = `${id}-error`;
 
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
        {...props}
      />
      {error && (
        <p id={errorId} role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Key accessibility details:

  • aria-invalid signals the error state to assistive technology
  • aria-describedby links the error message to the input
  • role="alert" announces the error when it appears
  • useId() generates stable, unique IDs for SSR compatibility

Uncontrolled Forms and FormData

Not everything needs React state. For simple forms, FormData avoids re-renders entirely:

function SearchForm({ onSearch }: { onSearch: (q: string) => void }) {
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    onSearch(data.get('query') as string);
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="query" type="search" required />
      <button type="submit">Search</button>
    </form>
  );
}

React 19's form actions build on this pattern with server-side form handling.

Validation Strategy

Three levels of validation, each serving a different purpose:

  1. Client-side (instant) — UX feedback, not security. Use onChange or onBlur validation for immediate feedback.
  2. Submission (pre-send) — validate the entire form before sending. Catch missing fields, format issues.
  3. Server-side (authoritative) — the only validation that matters for security. Never trust the client.
// Validate on blur for individual field feedback
<input onBlur={() => validateField('email', form.email)} />
 
// Validate on submit for form-level checks
const handleSubmit = (e: FormEvent) => {
  e.preventDefault();
  const errors = validateForm(form);
  if (Object.keys(errors).length) {
    setErrors(errors);
    focusFirstError(errors); // move focus to first invalid field
    return;
  }
  submit(form);
};

Focus Management on Errors

After validation fails, move focus to the first invalid field. This is critical for keyboard and screen reader users:

function focusFirstError(errors: Record<string, string>) {
  const firstKey = Object.keys(errors)[0];
  const el = document.getElementById(firstKey);
  el?.focus();
}

Interview Signal

Form questions test attention to detail. Senior signals:

  1. Accessibility-first — every input labeled, errors announced, focus managed
  2. Validation layering — client for UX, server for security
  3. Performance awareness — knowing when uncontrolled forms avoid unnecessary renders
  4. Progressive enhancement — forms that work without JavaScript (server actions, native validation)