Kunal Jain

Controlled Form Components: A Three-Layer Architecture for React Forms

May 19, 2026

How we built a reusable form component system that eliminated boilerplate, standardised error handling, and kept our forms type-safe across 100+ screens.


1. The Problem

Forms are the backbone of any data-intensive application. A finance platform can have hundreds of them - loan applications, customer profiles, payment entries, configuration settings, approval workflows.

Without structure, every form becomes a unique snowflake:

  • Field labels styled differently in every module
  • Error messages positioned inconsistently (some above, some below, some in toast notifications)
  • Validation wired directly inside components, impossible to test
  • Change handlers duplicated across every input
  • Required-field indicators forgotten in some places, doubled in others
  • Ref forwarding broken on custom inputs, breaking form library integration

Over time, the cost of these inconsistencies compounds. A simple "add a field" task requires reviewing how five other forms did it to get the details right.

We built a three-layer architecture for form components that solves this.


2. The Three Layers at a Glance

Every form input is composed of three layers, each with a single responsibility:

The dependency direction is one-way: ControlledFormField → UI primitive. Each layer knows nothing about the layers above it.


3. Layer 1: UI Primitives

These are the raw, stateless input components. They accept value, onChange, error state, and standard HTML attributes - nothing more.

// components/ui/input.tsx

import * as React from "react"

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  error?: boolean
}

export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ error, className, ...props }, ref) => {
    return (
      <input
        ref={ref}
        className={cn(
          "flex h-10 w-full rounded-md border px-3 py-2 text-sm",
          "focus-visible:outline-none focus-visible:ring-2",
          error
            ? "border-destructive ring-destructive/30"
            : "border-input ring-primary/30",
          className,
        )}
        {...props}
      />
    )
  },
)
Input.displayName = "Input"

Rules:

  • No label, no error message, no layout. Just the input element.
  • Accept an error?: boolean prop only for visual styling (red border).
  • Forward refs - the layer above needs to attach the form library's ref.
  • Use React.forwardRef so the parent can pass field.ref.

4. Layer 2: FormField Wrappers

These add the structural elements around a primitive: label, required asterisk, contextual help tooltip, description text, and error display.

// components/form/form-field.tsx

import * as React from "react"
import type { FieldError } from "react-hook-form"

interface FormFieldProps {
  label?: React.ReactNode
  error?: FieldError
  hideAsterisk?: boolean
  children: React.ReactNode
  description?: React.ReactNode
  contextualHelp?: React.ReactNode
  htmlFor?: string
  showError?: boolean
}

export function FormField({
  label,
  error,
  hideAsterisk,
  description,
  children,
  contextualHelp,
  htmlFor,
  showError = true,
}: FormFieldProps) {
  return (
    <div className="relative flex flex-col gap-1">
      {label && (
        <div className="text-sm font-medium">
          <label htmlFor={htmlFor}>{label}</label>
          {!hideAsterisk && <span className="ml-1 text-destructive">*</span>}
          {contextualHelp && <HelpIcon tooltip={contextualHelp} />}
        </div>
      )}
      {children}
      {showError && error && (
        <span className="text-xs font-medium text-destructive">
          {error.message}
        </span>
      )}
      {!error && description && (
        <span className="text-xs font-medium text-muted-foreground">
          {description}
        </span>
      )}
    </div>
  )
}

Then each primitive gets its own wrapper that composes FormField with the specific UI component:

// components/form/form-input.tsx

import * as React from "react"
import { Input, InputProps } from "../ui/input"
import { FormField, FormFieldProps } from "./form-field"

interface FormInputFieldProps
  extends Omit<InputProps, "error">, Omit<FormFieldProps, "children"> {}

export const FormInputField = React.forwardRef<
  HTMLInputElement,
  FormInputFieldProps
>(
  (
    { label, error, hideAsterisk, description, contextualHelp, showError, ...props },
    ref,
  ) => {
    return (
      <FormField
        htmlFor={props.id ?? props.name}
        label={label}
        error={error}
        showError={showError}
        hideAsterisk={hideAsterisk}
        description={description}
        contextualHelp={contextualHelp}>
        <Input error={!!error} ref={ref} id={props.id ?? props.name} {...props} />
      </FormField>
    )
  },
)
FormInputField.displayName = "FormInputField"

Key design decisions:

  • FormField is generic - It doesn't know what child it wraps. The same component handles inputs, selects, textareas, and date pickers.
  • Error is typed as FieldError from react-hook-form - Not a string. This lets the component render nested or array-form errors if they exist.
  • Error position is consistent - Always below the input, in the same style. No decisions needed per form.
  • Asterisk is automatic - Required fields get a red * by default. Opt out with hideAsterisk rather than opt in.

5. Layer 3: Controlled Components

This is the bridge between react-hook-form and the FormField layer. It imports Controller or useController from react-hook-form and wires up field, fieldState, and formState.

// components/controlled/controlled-input.tsx

import * as React from "react"
import { Controller, type FieldValues, type UseControllerProps } from "react-hook-form"
import { FormInputField } from "../form/form-input"

interface ControlledInputProps<TFieldValues extends FieldValues = FieldValues>
  extends Omit<UseControllerProps<TFieldValues>, "defaultValue">,
    Omit<
      React.ComponentProps<typeof FormInputField>,
      "name" | "defaultValue" | "rules" | "disabled"
    > {}

export function ControlledInput<TFieldValues extends FieldValues>({
  control,
  name,
  rules,
  shouldUnregister,
  disabled,
  ...props
}: ControlledInputProps<TFieldValues>) {
  return (
    <Controller
      name={name}
      control={control}
      rules={rules}
      shouldUnregister={shouldUnregister}
      render={({ field, fieldState: { error } }) => (
        <FormInputField
          {...props}
          {...field}
          disabled={disabled || field.disabled}
          value={field.value ?? ""}
          error={error}
        />
      )}
    />
  )
}

For more complex inputs like selects, the pattern uses useController instead, allowing custom change handlers:

// components/controlled/controlled-select.tsx

import * as React from "react"
import { useController, type FieldValues, type UseControllerProps } from "react-hook-form"
import { FormSelectField } from "../form/form-select"

interface ControlledSelectProps<TFieldValues extends FieldValues = FieldValues>
  extends Omit<UseControllerProps<TFieldValues>, "defaultValue">,
    Omit<
      React.ComponentProps<typeof FormSelectField>,
      "name" | "defaultValue" | "rules" | "onBlur" | "onChange"
    > {
  onChange?: (value: any) => void
}

export function ControlledSelect<TFieldValues extends FieldValues>({
  control,
  name,
  rules,
  shouldUnregister,
  onChange,
  isDisabled,
  ...props
}: ControlledSelectProps<TFieldValues>) {
  const {
    field,
    fieldState: { error },
  } = useController({ control, name, rules, shouldUnregister })

  const onChangeCallback = React.useCallback(
    (value: any) => {
      onChange?.(value)
      field.onChange(value)
    },
    [onChange, field],
  )

  return (
    <FormSelectField
      {...props}
      {...field}
      error={error}
      onChange={onChangeCallback}
      value={field.value === undefined || field.value === "" ? null : field.value}
      isDisabled={field.disabled || isDisabled}
    />
  )
}

How the layers connect in a real form:

// features/products/components/product-form.tsx

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { ControlledInput } from "@/components/controlled/controlled-input"
import { ControlledSelect } from "@/components/controlled/controlled-select"

const schema = z.object({
  name: z.string().min(1, "Name is required"),
  categoryId: z.string().min(1, "Category is required"),
  price: z.number().min(0, "Price must be positive"),
})

type FormValues = z.infer<typeof schema>

export function ProductForm() {
  const { control, handleSubmit } = useForm<FormValues>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <ControlledInput
        control={control}
        name="name"
        label="Product Name"
        placeholder="Enter product name"
        description="This will appear on invoices"
      />
      <ControlledSelect
        control={control}
        name="categoryId"
        label="Category"
        options={[
          { label: "Electronics", value: "1" },
          { label: "Furniture", value: "2" },
        ]}
      />
      <ControlledInput
        control={control}
        name="price"
        label="Price"
        type="number"
      />
    </form>
  )
}

The component author never thinks about:

  • How to display an error (FormField handles it)
  • How to register the field with react-hook-form (Controller handles it)
  • How to merge refs (built into the Controlled component)
  • Whether to show a required asterisk (automatic unless hidden)

6. The Contract Between Layers

The power of this architecture comes from strict boundaries. Each layer owns exactly one concern and exposes a clean interface to the layer above.

LayerOwnsExposesDoes not know about
UI PrimitiveVisual rendering, keyboard interactionStandard HTML props + error?: booleanreact-hook-form, labels, layouts
FormFieldLabel, error display, layout, help textProps from primitive + label/error/description/contextualHelpreact-hook-form, form state
Controlledreact-hook-form integrationStandard form field propsValidation schemas, submit handlers

This layering means you can swap any layer independently:

  • Replace Input with a different design system component - only the primitive changes.
  • Change error display from inline text to a tooltip - only FormField changes.
  • Migrate from react-hook-form to a different form library - only the Controlled components change, and the API surface stays the same.

7. Handling Edge Cases

Ref forwarding

Custom components like selects and date pickers don't expose an input element directly. The Controller render prop provides field.ref, which must be attached to the underlying focusable element. The Controlled component owns this wiring - the consumer never sees it.

External change handlers

Sometimes a form needs to react to a field change before the value reaches react-hook-form. The Controlled component accepts an optional onChange prop that fires before field.onChange:

<ControlledSelect
  control={control}
  name="categoryId"
  label="Category"
  onChange={(value) => {
    // Reset sub-category when category changes
    form.setValue("subCategoryId", undefined)
  }}
/>

Disabled state

The disabled prop can come from two sources: the form field's own rules (computed by react-hook-form via field.disabled) or a component-level prop. The Controlled component merges them:

disabled={disabled || field.disabled}

Date pickers and complex inputs

For inputs that don't use the standard onChange/value contract (date pickers, file uploaders, OTP inputs), the Controlled component adapts the callback:

<Controller
  name={name}
  control={control}
  render={({ field, fieldState: { error } }) => (
    <FormDatePickerField
      {...props}
      {...field}
      error={error}
      onChange={(val) => {
        props.onChange?.(val)
        field.onChange(val)
      }}
    />
  )}
/>

8. What This Pattern Achieved

Consistency across 13+ input types

Every input - text fields, selects, checkboxes, switches, date pickers, file uploaders, password fields, OTP inputs, number fields, geo-location fields, badges, textareas - follows the exact same contract. A developer who knows one knows all.

Zero error-handling bugs

Before this pattern, error messages appeared in different positions, different colours, and different formats depending on who wrote the form. After, FormField is the single source of truth for error display. Bugs in error rendering are fixed once.

Forms that are readable at a glance

A form's JSX reads like a list of field declarations, not a mix of layout markup, state wiring, and event handlers:

<ControlledInput control={control} name="name" label="Name" />
<ControlledInput control={control} name="email" label="Email" type="email" />
<ControlledSelect control={control} name="role" label="Role" options={roles} />
<ControlledCheckbox control={control} name="isActive" label="Active" />

Easy to test

Each layer can be tested in isolation. UI primitives test visual states (focused, error, disabled). FormField tests label rendering and error positioning. Controlled components test react-hook-form integration with mock control objects.


9. Tradeoffs

Component count

Three layers means three files per input type. For a simple text input, this feels disproportionate. The payoff comes when you have 13 input types across 100 forms - the per-form savings dwarf the per-component cost.

Learning curve for new developers

New team members need to understand the three layers before they can comfortably add a new input type. The pattern is simple enough to explain in 10 minutes, but it's still conceptual overhead that a flat approach doesn't have.

Override temptation

The clean abstraction tempts developers to add flags for edge cases ("just one more prop, I promise"). Over time, FormField can accumulate conditional branches. We enforce that any new visual variant must be a new component, not a new prop.

Not a straitjacket

Despite the structure, the architecture never stops you from dropping down to the base layers when needed. If a form has a completely bespoke layout

  • a drag-and-drop question builder, a dynamic table of line items, a multi-step wizard with conditional sections - you can bypass the Controlled layer entirely and compose Controller directly with the UI primitives or even raw HTML elements. The three layers are helpers, not enforcers.

For the other 90% of forms - the repetitive create/edit screens, configuration panels, and data-entry grids - the abstraction eliminates hundreds of lines of boilerplate. And for the 10% that go off-piste, the base components are right there, unchanged and accessible.


10. When to Build This

This pattern pays off when:

  • Your app has 20+ form screens
  • You have 5+ different input types (text, select, date, checkbox, etc.)
  • You care about error-display consistency
  • You use a form library like react-hook-form or Formik

It's overkill for:

  • A landing page with one contact form
  • An internal tool with 3-4 forms that rarely change
  • A prototype where speed is the only metric

11. Closing Thoughts

The three-layer pattern for form components is unoriginal by design. It takes the well-known separation between data, presentation, and layout and applies it at the component level, with each layer owning exactly one concern.

The result is a form system where:

  • Adding a new input type means writing three small files, each with a clear template to follow.
  • Fixing a visual bug in error display means editing one file.
  • Upgrading react-hook-form means updating 13 small wrapper components instead of hunting through hundreds of form files.
  • Any developer can open any form in the codebase and immediately understand what fields it has and how they behave.