export type Validator = (value: string) => string | null;
export const required = (message = 'This field is required'): Validator => {
return (value) => (value.trim().length === 0 ? message : null);
};
export const minLength = (n: number, message?: string): Validator => {
return (value) =>
value.length < n ? message ?? `Must be at least ${n} characters` : null;
};
export const email = (message = 'Enter a valid email'): Validator => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return (value) => (re.test(value) ? null : message);
};
export function runValidators(value: string, validators: Validator[]): string | null {
for (const validate of validators) {
const result = validate(value);
if (result !== null) return result;
}
return null;
}
import { useState, useCallback, ChangeEvent, FocusEvent } from 'react';
import { Validator, runValidators } from './validators';
export interface FieldApi {
value: string;
error: string | null;
touched: boolean;
validate: () => boolean;
reset: () => void;
inputProps: {
value: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onBlur: (e: FocusEvent<HTMLInputElement>) => void;
};
}
export function useField(initial = '', validators: Validator[] = []): FieldApi {
const [value, setValue] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setValue(next);
if (touched) setError(runValidators(next, validators));
},
[touched, validators],
);
const handleBlur = useCallback(() => {
setTouched(true);
setError(runValidators(value, validators));
}, [value, validators]);
const validate = useCallback(() => {
const result = runValidators(value, validators);
setTouched(true);
setError(result);
return result === null;
}, [value, validators]);
const reset = useCallback(() => {
setValue(initial);
setError(null);
setTouched(false);
}, [initial]);
return {
value,
error,
touched,
validate,
reset,
inputProps: { value, onChange: handleChange, onBlur: handleBlur },
};
}
import React, { FormEvent } from 'react';
import { useField } from './useField';
import { required, minLength, email } from './validators';
export function SignupForm() {
const name = useField('', [required('Name is required')]);
const mail = useField('', [required(), email()]);
const password = useField('', [required(), minLength(8)]);
const fields = { name, email: mail, password };
const errorMap: Record<string, string | null> = {
name: name.error,
email: mail.error,
password: password.error,
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const results = Object.values(fields).map((f) => f.validate());
const allValid = results.reduce((ok, r) => ok && r, true);
if (!allValid) return;
console.log('submitting', { name: name.value, email: mail.value });
};
return (
<form onSubmit={handleSubmit} noValidate>
<label>
Name
<input {...name.inputProps} aria-invalid={!!name.error} />
</label>
{name.touched && name.error && <span className="err">{name.error}</span>}
<label>
Email
<input type="email" {...mail.inputProps} aria-invalid={!!mail.error} />
</label>
{mail.touched && mail.error && <span className="err">{mail.error}</span>}
<label>
Password
<input type="password" {...password.inputProps} aria-invalid={!!password.error} />
</label>
{password.touched && password.error && (
<span className="err">{password.error}</span>
)}
<button type="submit" disabled={Object.values(errorMap).some(Boolean)}>
Create account
</button>
</form>
);
}
This snippet shows a lightweight, dependency-free approach to form validation in React where each field owns its own state, validation rules, and error message rather than relying on a single monolithic form object. The core idea is that validation happens per field, on blur and on change, so users get immediate, scoped feedback instead of a wall of errors on submit.
In validators.ts, a Validator is just a function that takes a value and returns either null (valid) or a string error message. Small combinators like required, minLength, and email are factory functions that close over their parameters and return a Validator. The runValidators helper runs an ordered list and returns the first failing message, which mirrors how users expect to fix one problem at a time. Keeping validators as plain functions makes them trivially composable and testable in isolation.
The useField hook encapsulates everything a single input needs: its value, a touched flag, and a derived error. Validation runs eagerly inside handleChange only after the field has been touched, so a pristine field never shows red. handleBlur marks the field touched and forces a validation pass, catching the case where a user tabs through an empty required field. The hook exposes a validate method so a parent form can trigger every field imperatively on submit, and returns a ready-to-spread inputProps object to keep JSX clean.
In SignupForm, three independent useField calls each carry their own validator chain. The errorMap is built by reading each field's error, giving a single object suitable for rendering a summary or gating the submit button. On submit, handleSubmit calls every field's validate() and only proceeds when all pass — the Boolean reduce avoids short-circuiting so all errors surface at once.
The trade-off is a little boilerplate per field versus a schema library, but the payoff is full control, zero dependencies, and transparent state. This pattern scales well for small-to-medium forms and is easy to extend with async validators or debouncing later.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
Share this code
Here's the card — post it anywhere.