useFormField Hook With Field-Level Validation
A field-scoped form hook for the cases where react-hook-form is overkill. Tracks value, touched, blur, and an async validator with a single race-safe in-flight token.
By @leoeriksson
February 23, 2026
·
Updated May 20, 2026
729 views
13
4.5 (10)
// useFormField: the 80% case. Sync validator, returns props you spread on <input>.
const { useState, useCallback, useMemo } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useMemo: (f) => f(),
});
function useFormField(initial, validate) {
const [value, setValue] = useState(initial);
const [touched, setTouched] = useState(false);
const error = useMemo(() => (validate ? validate(value) : null), [validate, value]);
const onChange = useCallback((e) => {
const next = e && e.target ? e.target.value : e;
setValue(next);
}, []);
const onBlur = useCallback(() => setTouched(true), []);
const reset = useCallback(() => { setValue(initial); setTouched(false); }, [initial]);
return {
value,
touched,
error,
showError: touched && !!error,
inputProps: { value, onChange, onBlur },
reset,
};
}
function validateEmail(v) {
if (!v) return 'required';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) return 'must be an email';
return null;
}
// Drive the hook by calling onChange/onBlur directly, since we have no real input.
const f = useFormField('', validateEmail);
console.log('initial:', { value: f.value, error: f.error, showError: f.showError });
f.inputProps.onChange({ target: { value: 'me@x' } });
const f2 = useFormField('me@x', validateEmail);
console.log('typed me@x:', { error: f2.error, showError: f2.touched && !!f2.error });
const f3 = useFormField('[email protected]', validateEmail);
console.log('typed [email protected]:', { error: f3.error });The trick to making useFormField ergonomic is the inputProps object: consumers spread it on a JSX element and the hook owns every piece of behavior. I keep error as a derived value from useMemo, not state, because then validators are pure and a fresh validator function (closing over a different prop) takes effect immediately. The showError boolean encapsulates the rule that we only show the error AFTER the user has blurred the field once, which is the single biggest reason form UX feels respectful or hostile. Reset takes the form back to its original state and is the function I forget to expose half the time.
// Async field validation (e.g. /username/check). Late responses must not
// overwrite newer ones, so we keep a monotonically increasing token.
const { useState, useCallback, useRef, useEffect } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useRef: (init) => ({ current: init }),
useEffect: () => {},
});
function useFormFieldAsync(initial, validate) {
const [value, setValue] = useState(initial);
const [touched, setTouched] = useState(false);
const [error, setError] = useState(null);
const [validating, setValidating] = useState(false);
const tokenRef = useRef(0);
useEffect(() => {
if (!validate) return;
const myToken = ++tokenRef.current;
setValidating(true);
Promise.resolve().then(() => validate(value)).then((result) => {
if (tokenRef.current !== myToken) return; // stale
setError(result);
setValidating(false);
});
}, [value, validate]);
const onChange = useCallback((e) => {
const v = e && e.target ? e.target.value : e;
setValue(v);
}, []);
const onBlur = useCallback(() => setTouched(true), []);
return {
value, touched, error, validating,
showError: touched && !!error && !validating,
inputProps: { value, onChange, onBlur },
};
}
// Fake server: 'taken' returns 'taken'; everything else passes after 10ms.
async function checkUsername(name) {
await new Promise((r) => setTimeout(r, 10));
if (!name) return 'required';
if (name === 'taken') return 'username already taken';
return null;
}
(async () => {
const f = useFormFieldAsync('alex', checkUsername);
console.log('initial:', { value: f.value, validating: f.validating, error: f.error });
// Simulate two rapid edits; the older response would normally overwrite the newer.
f.inputProps.onChange({ target: { value: 'taken' } });
f.inputProps.onChange({ target: { value: 'alex' } });
await new Promise((r) => setTimeout(r, 30));
console.log('after race settles, latest token wins (real React would re-render here)');
})();Async validators are where most hand-rolled form hooks ship a bug. If the user types taken, then immediately corrects to alex, two validate() calls are in flight; if the taken response lands second, the field shows an error for a value that is no longer present. The tokenRef increments on every effect run; when a response comes back, we drop it unless it is still the latest token. The validating flag is what powers the spinner next to the field, and showError: touched && !!error && !validating is the rule that prevents the error message from blinking on every keystroke.
// Once useFormField is solid, you do not need a form library for small forms.
// Compose a couple of fields, derive `canSubmit`, and submit on a button click.
const { useState, useCallback, useMemo } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useCallback: (f) => f,
useMemo: (f) => f(),
});
function useFormField(initial, validate) {
const [value, setValue] = useState(initial);
const [touched, setTouched] = useState(false);
const error = useMemo(() => (validate ? validate(value) : null), [validate, value]);
const onChange = useCallback((e) => setValue(e && e.target ? e.target.value : e), []);
const onBlur = useCallback(() => setTouched(true), []);
return { value, touched, error, showError: touched && !!error,
inputProps: { value, onChange, onBlur } };
}
function validateEmail(v) {
if (!v) return 'required';
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) return 'must be an email';
return null;
}
function validatePassword(v) {
if (!v) return 'required';
if (v.length < 8) return 'at least 8 characters';
return null;
}
function SignInForm() {
const email = useFormField('', validateEmail);
const password = useFormField('', validatePassword);
const canSubmit = !email.error && !password.error;
function submit() {
if (!canSubmit) {
email.inputProps.onBlur();
password.inputProps.onBlur();
return { ok: false, errors: { email: email.error, password: password.error } };
}
return { ok: true, payload: { email: email.value, password: password.value } };
}
return { email, password, canSubmit, submit };
}
const form = SignInForm();
console.log('canSubmit (empty):', form.canSubmit);
console.log('submit attempt while invalid:', form.submit());
// Type some values
form.email.inputProps.onChange({ target: { value: '[email protected]' } });
form.password.inputProps.onChange({ target: { value: 'hunter22' } });
const form2 = SignInForm();
form2.email.inputProps.onChange({ target: { value: '[email protected]' } });
form2.password.inputProps.onChange({ target: { value: 'hunter22' } });
console.log('hook surface keys per field:', Object.keys(form.email).sort().join(','));This is the shape of every login or contact form I write that does not deserve react-hook-form (the threshold for me is roughly four fields). Each field is one hook call; canSubmit is a single AND across the field errors; the submit handler nudges every field to touched so error messages appear when the user clicks the disabled button. The submit result returns an object instead of throwing because the most common consumer is a parent that wants to show a toast on failure, not a global error boundary. For anything bigger, I graduate to a real form library, but the upgrade path is trivial because the validators are already pure.
