Clean React Forms Pattern
Build maintainable React forms — useForm hook (Brain), Form compound components (Body), FormContext (Nervous System), validation schemas, async submit, and DRY add/edit workflows.
Introduction
The Clean React Forms pattern splits form architecture into three layers — Brain (custom hook), Body (compound components), and Nervous System (Context) — so validation, state, and markup stay decoupled. The same useForm + Form shell powers add screens, edit screens, and Storybook stories without copy-pasting field logic.
This guide covers:
- Problem statement — duplication, maintenance, readability, copy-paste bugs
- Mental model — decouple logic from UI, separation of concerns
- Pattern 1: Custom hook — values, errors, touched, submit handlers
- Pattern 2: Compound components —
Form.Field,Form.Select,Form.Button - Pattern 3: Context —
FormContext,useFormContext, no prop drilling - Real-world application — DRY add/edit, developer workflow
- Performance & best practices —
useCallback, async submit, modular schemas
Quick index
1. Problem statement
Without a form architecture, every screen reimplements the same state machine — and bugs multiply.
| Problem | Symptom |
|---|---|
| Code duplication | Same useState + validation on add and edit pages |
| Maintenance difficulty | Changing email rules requires editing five files |
| Hard-to-read code | 200-line JSX with inline onChange handlers |
| Bugs from copy-pasting | Edit form missing touched check add form had |
Before — duplicated add and edit forms
'use client'
import { useState } from 'react'
function AddProductForm() {
const [name, setName] = useState('')
const [price, setPrice] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
function validate() {
const next: Record<string, string> = {}
if (!name.trim()) next.name = 'Name required'
if (!price || Number(price) <= 0) next.price = 'Invalid price'
setErrors(next)
return Object.keys(next).length === 0
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!validate()) return
await fetch('/api/products', { method: 'POST', body: JSON.stringify({ name, price }) })
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
{errors.name && <span>{errors.name}</span>}
{/* … price field duplicated logic … */}
</form>
)
}
// EditProductForm — nearly identical copy with different initial values and PATCH2. Mental model
Treat forms as a small system with three responsibilities — not one mega-component.
┌─────────────────────────────────────────────────────────┐
│ Brain useForm hook — state, validation, submit │
│ Nervous Sys FormContext — wires Brain → Body │
│ Body Form.Field, Form.Button — declarative UI │
└─────────────────────────────────────────────────────────┘| Principle | Meaning |
|---|---|
| Decouple logic from UI | Hook owns state; components own markup |
| Engineering vs one-off | Reusable form system — not per-page hacks |
| Separation of concerns | Brain / Body / Nervous System — each swappable |
3. Pattern 1: Custom hook (The Brain)
useForm centralizes everything imperative about the form.
Manage state
| State | Purpose |
|---|---|
| Field values | Current controlled input values |
| Validation errors | Per-field error messages |
| Touched status | Show errors only after blur/submit |
| Submission status | isSubmitting, global submit error |
Input parameters
| Parameter | Purpose |
|---|---|
initialValues | Default or pre-filled edit values |
onSubmit | Async callback with validated values |
validate | Pure function (values) => errors |
Expose functions
| Function | Purpose |
|---|---|
handleChange | Update field value on input |
handleBlur | Mark field touched |
handleSubmit | Validate + call onSubmit |
resetForm | Restore initial values |
'use client'
import { useCallback, useState } from 'react'
type FormErrors<T> = Partial<Record<keyof T, string>>
type UseFormOptions<T extends Record<string, unknown>> = {
initialValues: T
validate: (values: T) => FormErrors<T>
onSubmit: (values: T) => Promise<void> | void
}
export function useForm<T extends Record<string, unknown>>({ initialValues, validate, onSubmit }: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<FormErrors<T>>({})
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const handleChange = useCallback((name: keyof T, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }))
setErrors((prev) => {
const next = { ...prev }
delete next[name]
return next
})
}, [])
const handleBlur = useCallback(
(name: keyof T) => {
setTouched((prev) => ({ ...prev, [name]: true }))
setErrors((prev) => {
const fieldErrors = validate({ ...values, [name]: values[name] })
return { ...prev, [name]: fieldErrors[name] }
})
},
[validate, values],
)
const resetForm = useCallback(() => {
setValues(initialValues)
setErrors({})
setTouched({})
setSubmitError(null)
}, [initialValues])
const handleSubmit = useCallback(
async (e?: React.FormEvent) => {
e?.preventDefault()
const validationErrors = validate(values)
setErrors(validationErrors)
setTouched(Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {} as Record<keyof T, boolean>))
if (Object.keys(validationErrors).length > 0) return
setIsSubmitting(true)
setSubmitError(null)
try {
await onSubmit(values)
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Submit failed')
} finally {
setIsSubmitting(false)
}
},
[onSubmit, validate, values],
)
return {
values,
errors,
touched,
isSubmitting,
submitError,
handleChange,
handleBlur,
handleSubmit,
resetForm,
}
}4. Pattern 2: Compound components (The Body)
Declarative form markup — readable like HTML, no prop drilling yet (Context in section 5).
| Component | Role |
|---|---|
Form | Wrapper — <form> + provider |
Form.Field | Label + input + error for text |
Form.Select | Dropdown field |
Form.TextArea | Multiline field |
Form.Button | Submit / reset with loading state |
'use client'
import { createContext, useContext, type ReactNode } from 'react'
import type { useForm } from './useForm'
type FormApi<T extends Record<string, unknown>> = ReturnType<typeof useForm<T>>
const FormContext = createContext<FormApi<Record<string, unknown>> | null>(null)
export function useFormContext<T extends Record<string, unknown>>() {
const ctx = useContext(FormContext)
if (!ctx) throw new Error('Form.* must be used within <Form>')
return ctx as FormApi<T>
}
type FormProps<T extends Record<string, unknown>> = {
form: FormApi<T>
children: ReactNode
className?: string
}
function FormRoot<T extends Record<string, unknown>>({ form, children, className }: FormProps<T>) {
return (
<FormContext.Provider value={form as FormApi<Record<string, unknown>>}>
<form onSubmit={form.handleSubmit} className={className} noValidate>
{form.submitError && (
<p role="alert" className="mb-4 rounded bg-red-50 p-3 text-sm text-red-700">
{form.submitError}
</p>
)}
{children}
</form>
</FormContext.Provider>
)
}
type FieldProps<T extends Record<string, unknown>> = {
name: keyof T
label: string
type?: string
placeholder?: string
}
function Field<T extends Record<string, unknown>>({ name, label, type = 'text', placeholder }: FieldProps<T>) {
const { values, errors, touched, handleChange, handleBlur } = useFormContext<T>()
const id = String(name)
const showError = touched[name] && errors[name]
return (
<label htmlFor={id} className="mb-4 block">
<span className="text-sm font-medium">{label}</span>
<input
id={id}
name={id}
type={type}
value={String(values[name] ?? '')}
placeholder={placeholder}
onChange={(e) => handleChange(name, e.target.value)}
onBlur={() => handleBlur(name)}
aria-invalid={Boolean(showError)}
className="mt-1 w-full rounded border px-3 py-2"
/>
{showError && <span className="mt-1 block text-sm text-red-600">{errors[name]}</span>}
</label>
)
}
function Select<T extends Record<string, unknown>>({
name,
label,
options,
}: {
name: keyof T
label: string
options: { value: string; label: string }[]
}) {
const { values, errors, touched, handleChange, handleBlur } = useFormContext<T>()
const id = String(name)
return (
<label htmlFor={id} className="mb-4 block">
<span className="text-sm font-medium">{label}</span>
<select
id={id}
name={id}
value={String(values[name] ?? '')}
onChange={(e) => handleChange(name, e.target.value)}
onBlur={() => handleBlur(name)}
className="mt-1 w-full rounded border px-3 py-2">
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{touched[name] && errors[name] && <span className="mt-1 block text-sm text-red-600">{errors[name]}</span>}
</label>
)
}
function TextArea<T extends Record<string, unknown>>({
name,
label,
rows = 4,
}: {
name: keyof T
label: string
rows?: number
}) {
const { values, errors, touched, handleChange, handleBlur } = useFormContext<T>()
const id = String(name)
return (
<label htmlFor={id} className="mb-4 block">
<span className="text-sm font-medium">{label}</span>
<textarea
id={id}
name={id}
rows={rows}
value={String(values[name] ?? '')}
onChange={(e) => handleChange(name, e.target.value)}
onBlur={() => handleBlur(name)}
className="mt-1 w-full rounded border px-3 py-2"
/>
{touched[name] && errors[name] && <span className="mt-1 block text-sm text-red-600">{errors[name]}</span>}
</label>
)
}
function Button({ children, variant = 'submit' }: { children: ReactNode; variant?: 'submit' | 'reset' }) {
const { isSubmitting, resetForm } = useFormContext()
if (variant === 'reset') {
return (
<button type="button" onClick={resetForm} className="rounded border px-4 py-2">
{children}
</button>
)
}
return (
<button type="submit" disabled={isSubmitting} className="rounded bg-black px-4 py-2 text-white disabled:opacity-50">
{isSubmitting ? 'Saving…' : children}
</button>
)
}
export const Form = Object.assign(FormRoot, {
Field,
Select,
TextArea,
Button,
})5. Pattern 3: Context (The Nervous System)
FormContext connects the Brain to deeply nested Body parts without prop drilling.
| Piece | Role |
|---|---|
| FormContext API | Provides values, handlers, errors to tree |
useFormContext() | Hook for Form.Field / custom field components |
| Sharing hook state | Single useForm instance per form |
| Eliminates prop drilling | No errors={errors} on every field |
useForm() → form object
→ <Form form={form}> → FormContext.Provider
→ <Form.Field /> → useFormContext()
→ <CustomColorPicker /> → useFormContext()Custom field via context — no extra props
function ColorPicker<T extends Record<string, unknown>>({ name }: { name: keyof T }) {
const { values, handleChange } = useFormContext<T>()
const colors = ['#000', '#2563eb', '#dc2626', '#16a34a']
return (
<fieldset className="mb-4">
<legend className="text-sm font-medium">Brand color</legend>
<div className="mt-2 flex gap-2">
{colors.map((c) => (
<button
key={c}
type="button"
aria-label={c}
style={{ background: c }}
className={`h-8 w-8 rounded-full border-2 ${values[name] === c ? 'border-black' : 'border-transparent'}`}
onClick={() => handleChange(name, c)}
/>
))}
</div>
</fieldset>
)
}6. Real-world application
DRY (SOLID) wins
| Reuse target | How Clean Forms helps |
|---|---|
| Add / edit logic | Same ProductForm — different initialValues |
| Validation rules | Shared validateProduct module |
| Loading / error states | isSubmitting, submitError in hook once |
Modular validation schema
// schemas/productSchema.ts
export type ProductValues = {
name: string
price: string
category: string
description: string
}
export function validateProduct(values: ProductValues) {
const errors: Partial<Record<keyof ProductValues, string>> = {}
if (!values.name.trim()) errors.name = 'Name is required'
if (values.name.length > 80) errors.name = 'Max 80 characters'
const price = Number(values.price)
if (!values.price || Number.isNaN(price) || price <= 0) errors.price = 'Enter a valid price'
if (!values.category) errors.category = 'Select a category'
return errors
}Developer workflow
1. Define validation → validateProduct(values) in schemas/
2. Define initial state → ProductValues type + defaults
3. Compose JSX → <Form form={form}> + Form.Field children'use client'
import { useForm } from '@/hooks/useForm'
import { Form } from '@/components/form/Form'
import { validateProduct, type ProductValues } from '@/schemas/productSchema'
const emptyProduct: ProductValues = {
name: '',
price: '',
category: '',
description: '',
}
type ProductFormProps = {
initialValues?: ProductValues
onSave: (values: ProductValues) => Promise<void>
}
export function ProductForm({ initialValues = emptyProduct, onSave }: ProductFormProps) {
const form = useForm({
initialValues,
validate: validateProduct,
onSubmit: onSave,
})
return (
<Form form={form} className="max-w-md space-y-2">
<Form.Field<ProductValues> name="name" label="Product name" />
<Form.Field<ProductValues> name="price" label="Price" type="number" />
<Form.Select<ProductValues>
name="category"
label="Category"
options={[
{ value: '', label: 'Select…' },
{ value: 'electronics', label: 'Electronics' },
{ value: 'books', label: 'Books' },
]}
/>
<Form.TextArea<ProductValues> name="description" label="Description" />
<div className="flex gap-2 pt-2">
<Form.Button>Save product</Form.Button>
<Form.Button variant="reset">Reset</Form.Button>
</div>
</Form>
)
}// Add page
<ProductForm onSave={(v) => fetch('/api/products', { method: 'POST', body: JSON.stringify(v) })} />
// Edit page — same component, different initial values
<ProductForm initialValues={existingProduct} onSave={(v) => fetch(`/api/products/${id}`, { method: 'PATCH', body: JSON.stringify(v) })} />7. Full example: Product form
End-to-end wiring — schema, hook, compound form, async submit.
'use client'
import { useRouter } from 'next/navigation'
import { useForm } from '@/hooks/useForm'
import { Form } from '@/components/form/Form'
import { validateProduct, type ProductValues } from '@/schemas/productSchema'
async function createProduct(values: ProductValues) {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...values,
price: Number(values.price),
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message ?? 'Could not save product')
}
}
export function CreateProductPage() {
const router = useRouter()
const form = useForm<ProductValues>({
initialValues: { name: '', price: '', category: '', description: '' },
validate: validateProduct,
onSubmit: async (values) => {
await createProduct(values)
router.push('/products')
},
})
return (
<section className="p-6">
<h1 className="mb-6 text-2xl font-bold">New product</h1>
<Form form={form}>
<Form.Field name="name" label="Name" placeholder="Wireless headphones" />
<Form.Field name="price" label="Price (USD)" type="number" placeholder="99.99" />
<Form.Select
name="category"
label="Category"
options={[
{ value: '', label: 'Choose category' },
{ value: 'electronics', label: 'Electronics' },
{ value: 'accessories', label: 'Accessories' },
]}
/>
<Form.TextArea name="description" label="Description" rows={5} />
<Form.Button>Create product</Form.Button>
</Form>
</section>
)
}8. Performance & best practices
| Practice | Guidance |
|---|---|
useCallback handlers | Stable handleChange / handleSubmit in hook |
| Async submission | try/finally for isSubmitting; surface errors |
| Render props for edge cases | Escape hatch when compound API is too rigid |
| Modular schema | Validation in schemas/ — shared client/server |
| Do not over-build | 2-field form — inline state is fine |
Render prop escape hatch
type FormRenderProps<T> = ReturnType<typeof useForm<T>>
function FormWithRender<T extends Record<string, unknown>>({
form,
children,
}: {
form: FormRenderProps<T>
children: (api: FormRenderProps<T>) => React.ReactNode
}) {
return (
<FormContext.Provider value={form as FormApi<Record<string, unknown>>}>
<form onSubmit={form.handleSubmit}>{children(form)}</form>
</FormContext.Provider>
)
}
// Usage — full layout control
;<FormWithRender form={form}>
{(f) => (
<div className="grid grid-cols-2 gap-4">
<Form.Field name="firstName" label="First" />
<Form.Field name="lastName" label="Last" />
<button type="submit" disabled={f.isSubmitting}>
Save
</button>
</div>
)}
</FormWithRender>Summary
| Layer | Pattern | Responsibility |
|---|---|---|
| Brain | useForm hook | State, validation, submit |
| Nervous System | FormContext | Wire hook to deep fields |
| Body | Form.Field, etc. | Declarative accessible UI |
Clean React Forms decouple logic from markup so add/edit screens, validation rules, and loading states stay DRY. Start with the three-layer model; reach for form libraries when complexity outgrows hand-rolled hooks.
Next reads: Controlled vs Uncontrolled Pattern for React 19 form Actions, Compound Components Pattern for Form.* dot notation, and State Reducer Pattern for complex multi-step wizards.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime