Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsFormsCustom HooksCompound Components

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.

Jul 5, 202615 min read

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:

  1. Problem statement — duplication, maintenance, readability, copy-paste bugs
  2. Mental model — decouple logic from UI, separation of concerns
  3. Pattern 1: Custom hook — values, errors, touched, submit handlers
  4. Pattern 2: Compound components — Form.Field, Form.Select, Form.Button
  5. Pattern 3: Context — FormContext, useFormContext, no prop drilling
  6. Real-world application — DRY add/edit, developer workflow
  7. Performance & best practices — useCallback, async submit, modular schemas

Note

This pattern complements Controlled vs Uncontrolled Components — Clean Forms standardizes controlled multi-field apps. For simple one-field forms or Server Actions with FormData, a lighter approach may suffice.

Quick index

#Section
1Problem statement
2Mental model
3Pattern 1: Custom hook (Brain)
4Pattern 2: Compound components (Body)
5Pattern 3: Context (Nervous System)
6Real-world application
7Full example: Product form
8Performance & best practices

1. Problem statement

Without a form architecture, every screen reimplements the same state machine — and bugs multiply.

ProblemSymptom
Code duplicationSame useState + validation on add and edit pages
Maintenance difficultyChanging email rules requires editing five files
Hard-to-read code200-line JSX with inline onChange handlers
Bugs from copy-pastingEdit form missing touched check add form had

Before — duplicated add and edit forms

tsx
'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 PATCH

Warning

Copy-pasting form logic between add and edit screens is the fastest path to validation drift — one screen gets the new rule, the other does not.

Back to index


2. Mental model

Treat forms as a small system with three responsibilities — not one mega-component.

plaintext
┌─────────────────────────────────────────────────────────┐
│  Brain        useForm hook — state, validation, submit  │
│  Nervous Sys  FormContext — wires Brain → Body          │
│  Body         Form.Field, Form.Button — declarative UI  │
└─────────────────────────────────────────────────────────┘
PrincipleMeaning
Decouple logic from UIHook owns state; components own markup
Engineering vs one-offReusable form system — not per-page hacks
Separation of concernsBrain / Body / Nervous System — each swappable

Tip

Brain answers "what is form state?" Body answers "how does it look?" Nervous System answers "how do deep fields receive state without props?"

Note

This mirrors Container-Presenter and Compound Components — Clean Forms combines both for multi-field controlled inputs specifically.

Back to index


3. Pattern 1: Custom hook (The Brain)

useForm centralizes everything imperative about the form.

Manage state

StatePurpose
Field valuesCurrent controlled input values
Validation errorsPer-field error messages
Touched statusShow errors only after blur/submit
Submission statusisSubmitting, global submit error

Input parameters

ParameterPurpose
initialValuesDefault or pre-filled edit values
onSubmitAsync callback with validated values
validatePure function (values) => errors

Expose functions

FunctionPurpose
handleChangeUpdate field value on input
handleBlurMark field touched
handleSubmitValidate + call onSubmit
resetFormRestore initial values
tsx
'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,
  }
}

Back to index


4. Pattern 2: Compound components (The Body)

Declarative form markup — readable like HTML, no prop drilling yet (Context in section 5).

ComponentRole
FormWrapper — <form> + provider
Form.FieldLabel + input + error for text
Form.SelectDropdown field
Form.TextAreaMultiline field
Form.ButtonSubmit / reset with loading state
tsx
'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,
})

Tip

Compound Form.Field keeps label + input + error colocated — consumers write <Form.Field name="email" label="Email" /> instead of repeating accessibility wiring.

Back to index


5. Pattern 3: Context (The Nervous System)

FormContext connects the Brain to deeply nested Body parts without prop drilling.

PieceRole
FormContext APIProvides values, handlers, errors to tree
useFormContext()Hook for Form.Field / custom field components
Sharing hook stateSingle useForm instance per form
Eliminates prop drillingNo errors={errors} on every field
plaintext
useForm()  →  form object
  →  <Form form={form}>  →  FormContext.Provider
        →  <Form.Field />  →  useFormContext()
        →  <CustomColorPicker />  →  useFormContext()

Custom field via context — no extra props

tsx
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>
  )
}

Warning

Without Context, large forms pass values, errors, handleChange through wrapper components that do not use them — pure prop drilling noise. Context scopes state to one form boundary.

Note

Keep one Context per form instance — not a global form store. Multi-form pages need separate <Form form={…}> wrappers. See Provider Pattern for narrow context scope guidance.

Back to index


6. Real-world application

DRY (SOLID) wins

Reuse targetHow Clean Forms helps
Add / edit logicSame ProductForm — different initialValues
Validation rulesShared validateProduct module
Loading / error statesisSubmitting, submitError in hook once

Modular validation schema

tsx
// 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

plaintext
1. Define validation   →  validateProduct(values) in schemas/
2. Define initial state →  ProductValues type + defaults
3. Compose JSX         →  <Form form={form}> + Form.Field children
tsx
'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>
  )
}
tsx
// 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) })} />

Interview Answer

"Clean Forms vs React Hook Form / Formik?"

Libraries like React Hook Form already implement Brain + performance optimizations at scale. The Clean Forms pattern teaches the architecture those libraries encode — build it by hand for learning, small apps, or design-system control; adopt a library when forms grow to field arrays, async schema validation, and nested objects.

Back to index


7. Full example: Product form

End-to-end wiring — schema, hook, compound form, async submit.

tsx
'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>
  )
}

Note

Map string inputs to numbers in onSubmit or API layer — keep form values as strings for controlled <input type="number"> compatibility and simpler validation messages.

Back to index


8. Performance & best practices

PracticeGuidance
useCallback handlersStable handleChange / handleSubmit in hook
Async submissiontry/finally for isSubmitting; surface errors
Render props for edge casesEscape hatch when compound API is too rigid
Modular schemaValidation in schemas/ — shared client/server
Do not over-build2-field form — inline state is fine

Render prop escape hatch

tsx
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>

Performance

useCallback on handleChange and handleBlur prevents unnecessary re-renders if you memoize custom field components with React.memo. For small forms the gain is negligible — profile first.

Warning

Do not store form values in Context above the form boundary app-wide — that recreates global state and re-renders unrelated screens. Scope Context to <Form> only.

Tip

Production checklist

  • useForm hook — values, errors, touched, submit state
  • Pure validate(values) in schemas/
  • Form compound components + useFormContext
  • Shared form component for add/edit
  • Async onSubmit with error handling
  • useCallback for handlers in large forms
  • Render prop for irregular layouts
  • Consider React Hook Form when field arrays / nested objects dominate
  • React 19 Server Actions for simple server-posted forms — see controlled/uncontrolled guide

Back to index


Summary

LayerPatternResponsibility
BrainuseForm hookState, validation, submit
Nervous SystemFormContextWire hook to deep fields
BodyForm.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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime