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: Controlled → FormField → 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?: booleanprop only for visual styling (red border). - Forward refs - the layer above needs to attach the form library's ref.
- Use
React.forwardRefso the parent can passfield.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:
FormFieldis generic - It doesn't know what child it wraps. The same component handles inputs, selects, textareas, and date pickers.- Error is typed as
FieldErrorfrom 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 withhideAsteriskrather 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 (
FormFieldhandles it) - How to register the field with react-hook-form (
Controllerhandles it) - How to merge refs (built into the
Controlledcomponent) - 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.
| Layer | Owns | Exposes | Does not know about |
|---|---|---|---|
| UI Primitive | Visual rendering, keyboard interaction | Standard HTML props + error?: boolean | react-hook-form, labels, layouts |
FormField | Label, error display, layout, help text | Props from primitive + label/error/description/contextualHelp | react-hook-form, form state |
Controlled | react-hook-form integration | Standard form field props | Validation schemas, submit handlers |
This layering means you can swap any layer independently:
- Replace
Inputwith a different design system component - only the primitive changes. - Change error display from inline text to a tooltip - only
FormFieldchanges. - Migrate from react-hook-form to a different form library - only the
Controlledcomponents 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
Controlledlayer entirely and composeControllerdirectly 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.