useValidatedState(initialState, validator) is a useState that answers one extra question: does the value it is holding right now pass? You get back the usual state and setter, plus a third slot — the validity of the current state. Every form wants this pairing, whether that is an email field and whether the address is well-formed or a password and whether it clears the rules. The interesting part is not working out the answer. It is deciding where the answer lives.
function useValidatedState<T, V>(
initialState: T | (() => T), // seeds the state, exactly like useState
validator: (value: T) => V, // decides whether a value passes
): [
T, // the current state
(next: T | ((current: T) => T)) => void, // the setter — useState's own
V, // the validity of the CURRENT state
];
A boolean validator is the common case:
const [email, setEmail, isValid] = useValidatedState('', (v) => v.includes('@'));
// isValid is false on the very first render — '' has no '@' in it.
setEmail('[email protected]'); // isValid is true on the next render
Whatever the validator returns is what you get back, so a richer answer costs the hook nothing:
const check = (v) => ({ valid: v.length >= 8, errors: v.length >= 8 ? [] : ['too short'] });
const [password, setPassword, validity] = useValidatedState('abc', check);
// validity is { valid: false, errors: ['too short'] } — on render 1, already.
setState call must cost exactly one render. If your version renders twice per keystroke, something is being stored that should not be.useState's setter. Functional updates, batching, lazy initial state, the bail-out when you set an unchanged value — inherit all of it by passing React's own setter straight through instead of wrapping it.(v) => v.length >= minLength closes over a prop. If minLength changes while the state sits still, the validity still has to move.0, '', false and null are values to be validated, not absences to skip.