useForm — A Reusable Form State Hook

Every form in the blog application follows the same pattern: a state object for field values, a state object for errors, a generic change handler, validation before submission, and a reset after success. Writing this 40-line setup for every form creates significant duplication. A useForm hook extracts the entire pattern into a single call, accepting an initial values object and a validation function, and returning everything a form component needs: the current values, errors, change/blur handlers, a submit wrapper, and a reset function.

useForm Hook Implementation

// src/hooks/useForm.js
import { useState, useCallback } from "react";

export function useForm({ initialValues, validate }) {
    const [values,   setValues]   = useState(initialValues);
    const [errors,   setErrors]   = useState({});
    const [touched,  setTouched]  = useState({});   // tracks which fields were blurred
    const [isSubmitting, setIsSubmitting] = useState(false);

    // Generic handler: works for all text/select/textarea inputs with a name attr
    const handleChange = useCallback((e) => {
        const { name, value, type, checked } = e.target;
        setValues((prev) => ({
            ...prev,
            [name]: type === "checkbox" ? checked : value,
        }));
        // Clear error when user starts fixing a field
        if (errors[name]) {
            setErrors((prev) => ({ ...prev, [name]: null }));
        }
    }, [errors]);

    // Mark field as touched on blur — shows error if invalid
    const handleBlur = useCallback((e) => {
        const { name } = e.target;
        setTouched((prev) => ({ ...prev, [name]: true }));
        if (validate) {
            const fieldErrors = validate(values);
            setErrors((prev) => ({ ...prev, [name]: fieldErrors[name] ?? null }));
        }
    }, [values, validate]);

    // Manually update a field value (for custom inputs like TagInput)
    const setFieldValue = useCallback((name, value) => {
        setValues((prev) => ({ ...prev, [name]: value }));
    }, []);

    // Wrap the submit handler: validate all, prevent default, set submitting state
    const handleSubmit = useCallback((onSubmit) => async (e) => {
        e.preventDefault();
        // Mark all fields as touched to show any hidden errors
        const allTouched = Object.keys(values).reduce(
            (acc, key) => ({ ...acc, [key]: true }), {}
        );
        setTouched(allTouched);

        // Validate all fields
        const newErrors = validate ? validate(values) : {};
        setErrors(newErrors);
        if (Object.keys(newErrors).some((k) => newErrors[k])) return;

        setIsSubmitting(true);
        try {
            await onSubmit(values);
        } finally {
            setIsSubmitting(false);
        }
    }, [values, validate]);

    const reset = useCallback(() => {
        setValues(initialValues);
        setErrors({});
        setTouched({});
    }, [initialValues]);

    // Helper: should we show the error for this field?
    const getFieldError = useCallback((name) => {
        return touched[name] ? errors[name] : null;
    }, [touched, errors]);

    return {
        values,
        errors,
        touched,
        isSubmitting,
        handleChange,
        handleBlur,
        handleSubmit,
        setFieldValue,
        reset,
        getFieldError,
    };
}
Note: The touched object tracks which fields the user has interacted with (blurred at least once). Showing errors only for touched fields is important UX: presenting “Title is required” before the user has even tried to fill in the title is confusing and discouraging. After the form is submitted (all fields marked as touched in handleSubmit), all errors become visible simultaneously, giving the user a complete picture of what needs to be fixed.
Tip: Design useForm to accept a validation function rather than embedding validation rules inside the hook. This separates concerns: the hook manages state mechanics, the consuming component (or a separate validators module) defines the rules. The validation function receives the current values and returns an errors object: const validate = (values) => { const errs = {}; if (!values.title) errs.title = "Required"; return errs; }. This makes validations easy to unit test independently.
Warning: If initialValues is defined as an object literal inline in the component (useForm({ initialValues: { title: "" } })), a new object is created on every render, causing useCallback in the hook to recreate all its functions on every render too. Define initial values outside the component or with useMemo: const initialValues = useMemo(() => ({ title: "" }), []). Alternatively, use the hook with a constant defined at module scope.

Using useForm in the Post Editor

import { useForm }   from "@/hooks/useForm";
import { postsApi }  from "@/services/posts";
import { useNavigate } from "react-router-dom";

const INITIAL = { title: "", body: "", slug: "", status: "draft", tags: [] };

function validate(values) {
    const errors = {};
    if (!values.title.trim())              errors.title = "Title is required";
    if (!values.body.trim())               errors.body  = "Body is required";
    if (!/^[a-z0-9-]+$/.test(values.slug)) errors.slug  = "Slug: lowercase, numbers, hyphens only";
    return errors;
}

export default function PostEditorPage() {
    const navigate = useNavigate();
    const {
        values, isSubmitting, handleChange, handleBlur,
        handleSubmit, setFieldValue, getFieldError,
    } = useForm({ initialValues: INITIAL, validate });

    const onSubmit = handleSubmit(async (formValues) => {
        const post = await postsApi.create(formValues);
        navigate(`/posts/${post.id}`);
    });

    return (
        <form onSubmit={onSubmit} noValidate className="space-y-4">
            <div>
                <label htmlFor="title">Title</label>
                <input id="title" name="title" value={values.title}
                       onChange={handleChange} onBlur={handleBlur} />
                {getFieldError("title") && (
                    <p className="text-red-500 text-sm">{getFieldError("title")}</p>
                )}
            </div>
            {/* Tags use setFieldValue since TagInput doesn't emit native events */}
            <TagInput
                value={values.tags}
                onChange={(tags) => setFieldValue("tags", tags)}
            />
            <button type="submit" disabled={isSubmitting}>
                {isSubmitting ? "Saving..." : "Publish"}
            </button>
        </form>
    );
}

Common Mistakes

Mistake 1 — Showing errors before fields are touched

❌ Wrong — errors shown immediately on load:

{errors.title && <p>{errors.title}</p>}   // shows before user types!

✅ Correct — use getFieldError which checks touched state:

{getFieldError("title") && <p>{getFieldError("title")}</p>}   // ✓

Mistake 2 — Inline object for initialValues causes re-renders

✅ Correct — define initialValues at module scope or with useMemo to keep it stable.

Quick Reference

Return Purpose
values Current field values object
handleChange Generic onChange for native inputs
handleBlur Marks field as touched, validates
handleSubmit(fn) Returns onSubmit handler; validates all before calling fn
setFieldValue(name, val) Programmatic field update (custom inputs)
getFieldError(name) Returns error only if field was touched
isSubmitting True while the submit fn is running
reset() Reset to initialValues

🧠 Test Yourself

Why does useForm accept a validate function as a parameter rather than implementing validation rules inside the hook?