I've used React Hook Form and Formik on production projects and they both work well. But on smaller projects, and on forms with fewer than ten fields, I find that native React form handling is simpler, faster to build, and easier to understand.
Here's how I build forms without a library, including validation, error handling, and submission.
The basic pattern
interface FormData {
email: string
name: string
message: string
}
interface FormErrors {
email?: string
name?: string
message?: string
}
function ContactForm() {
const [data, setData] = useState<FormData>({ email: '', name: '', message: '' })
const [errors, setErrors] = useState<FormErrors>({})
const [submitting, setSubmitting] = useState(false)
function validate(values: FormData): FormErrors {
const errs: FormErrors = {}
if (!values.email) errs.email = 'Required'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errs.email = 'Invalid email'
if (!values.name) errs.name = 'Required'
if (!values.message) errs.message = 'Required'
else if (values.message.length < 20) errs.message = 'At least 20 characters'
return errs
}
function handleChange(field: keyof FormData, value: string) {
setData((prev) => ({ ...prev, [field]: value }))
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
const validationErrors = validate(data)
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
setSubmitting(true)
try {
await submitContact(data)
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<Field label="Email" value={data.email} error={errors.email} onChange={(v) => handleChange('email', v)} />
<Field label="Name" value={data.name} error={errors.name} onChange={(v) => handleChange('name', v)} />
<TextArea label="Message" value={data.message} error={errors.message} onChange={(v) => handleChange('message', v)} />
<button type="submit" disabled={submitting}>{submitting ? 'Sending...' : 'Send'}</button>
</form>
)
}
This is about 50 lines for a fully validated, accessible form. It handles required fields, format validation, clear-on-type error dismissal, and submit-state management.
Type-safe field helpers
The handleChange function above uses keyof FormData to ensure you can't pass a field name that doesn't exist. This is one place where TypeScript genuinely prevents bugs: if you rename a field, every call site that references the old name becomes a type error.
For forms with many fields, I extract this into a hook:
function useForm<T extends Record<string, string>>(initialValues: T) {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
function setValue(field: keyof T, value: string) {
setValues((prev) => ({ ...prev, [field]: value }))
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: undefined }))
}
function setError(field: keyof T, message: string) {
setErrors((prev) => ({ ...prev, [field]: message }))
}
function markTouched(field: keyof T) {
setTouched((prev) => ({ ...prev, [field]: true }))
}
function reset() {
setValues(initialValues)
setErrors({})
setTouched({})
}
return { values, errors, touched, setValue, setError, markTouched, reset }
}
This is 25 lines and covers the form state management that React Hook Form and Formik both provide. It doesn't cover every edge case they handle (array fields, nested objects, field-level async validation), but for the forms I build most often, it's enough.
Validation timing
Most form libraries validate on blur, on change, or on submit. I validate on submit, with one exception: I clear field-level errors as the user types. This gives a good user experience without the annoyance of seeing error messages while you're still typing.
function handleChange(field: keyof FormData, value: string) {
setData((prev) => ({ ...prev, [field]: value }))
// Clear the error for this field as the user types
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
}
For fields that benefit from immediate feedback (email format, password strength), I add a separate useEffect that validates after a debounce:
const debouncedEmail = useDebounce(data.email, 500)
useEffect(() => {
if (!debouncedEmail) return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(debouncedEmail)) {
setErrors((prev) => ({ ...prev, email: 'Invalid email format' }))
}
}, [debouncedEmail])
When to reach for a library
Use a form library when:
- The form has more than 15 fields
- Fields are dynamic (add/remove rows)
- You need field-level async validation (uniqueness checks)
- The form has complex conditional logic (field B is required only when field A has value X)
For everything else, native form handling is less code, fewer dependencies, and simpler to debug.
The React 19 angle
React 19 introduces form actions and the useActionState hook, which simplify server-side form handling even further. For forms that submit to a server action, the framework handles the pending state, error recovery, and progressive enhancement.
This doesn't replace client-side validation, but it reduces the boilerplate for the submit-and-handle-errors flow. If you're on Next.js with the App Router, server actions are already available and worth considering for forms that write to a database.
I've been reaching for React Hook Form on every project, even simple ones with three fields. This made me reconsider. The useForm hook you show is 25 lines and covers 90% of what I use RHF for.
The validation timing approach (validate on submit, clear on type) is exactly right from a UX perspective. Showing errors while the user is still typing is one of the most annoying patterns on the web.