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.

JavaScript
Frontend
3 snippets
react
hooks
code-template
error-handling
leoeriksson

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.