Hazem Azzam

All posts
Writing

Controlled Inputs in React: One Controller for Any Component

Controlled components power validation and resets, but wiring value and onChange for every field is tedious. Learn how a thin react-hook-form wrapper uses cloneElement and prop mapping to turn any UI component into a controlled field.

4 min read
reactcontrolled-inputsreact-hook-formtypescriptforms

Controlled inputs in React: one adapter, any component

Every form library eventually asks the same question: how do you connect your UI component to our state?

In React, the answer is almost always controlled components — the parent owns value (or checked, or selectedKeys) and passes an updater (onChange, onCheckedChange, onValueChange). That pattern is simple for a native <input>. It gets repetitive when you mix design-system switches, comboboxes, and custom widgets, each with different prop names and event shapes.

This post walks through that problem, how react-hook-form solves it with Controller, and how a small wrapper can make the wiring direct: pass a child element (or a render function), name the field, and go.

Controlled vs uncontrolled (the 30-second version)

A controlled input displays whatever value lives in React state. When the user types, you call setState (or field.onChange) and React re-renders with the new value.

An uncontrolled input keeps its own DOM state. You read it once with a ref when the form submits.

Controlled inputs are the default choice when you need:

  • Instant validation and error messages
  • Dependent fields (city list after country changes)
  • Resetting the form from code
  • A single source of truth for submit payloads

The cost is boilerplate: value, onChange, onBlur, name, ref, and often id for labels — wired by hand for every field.

What react-hook-form gives you

react-hook-form stores field values in a form state object and exposes register for native inputs. For everything else, you use Controller (or useController): you provide name, control, and a render function that receives field and fieldState.

Conceptually, Controller is the adapter:

<Controller
  name="email"
  control={control}
  render={({ field, fieldState }) => (
    <input
      value={field.value ?? ""}
      onChange={field.onChange}
      onBlur={field.onBlur}
      name={field.name}
      ref={field.ref}
    />
  )}
/>

That works, but every field repeats the same glue. Labels, htmlFor, error text, and mapping non-standard props (checked / onCheckedChange on a Switch) are still on you.

The idea: a Controlled wrapper

A practical pattern is one component that:

  1. Resolves control from FormProvider or an explicit prop
  2. Delegates to FormField / Controller under the hood
  3. Injects the right props into whatever child you pass

Two ergonomics cover almost all cases:

1. Clone a single element (the direct path)

If your child is one React element (native input, Switch, Select root), clone it and merge field props:

<Controlled name="email" label="Email">
  <input type="email" />
</Controlled>

Under the hood, something like React.cloneElement sets value, onChange, onBlur, name, ref, and id, plus accessibility hints when validation fails.

2. Render prop when clone is not enough

When the child is not a single element, or you need layout around the input:

<Controlled name="title" label="Title">
  {(fieldProps) => (
    <input
      value={fieldProps.value as string}
      onChange={(e) => fieldProps.onChange(e)}
      onBlur={fieldProps.onBlur as React.FocusEventHandler<HTMLInputElement>}
      id={fieldProps.id as string}
      name={fieldProps.name as string}
    />
  )}
</Controlled>

Passing the event into onChange matters: a default toFieldValue helper can read event.target.value, so you do not have to unwrap every native input by hand.

Mapping prop names: not every component uses value

Radix and shadcn-style components often use different contracts:

Component styleValue propChange prop
<input>valueonChange
SwitchcheckedonCheckedChange
Select / ComboboxvalueonValueChange

A thin adapter accepts valueProp and changeProp:

<Controlled
  name="active"
  label="Active"
  valueProp="checked"
  changeProp="onCheckedChange"
  emptyValue={false}
>
  <Switch />
</Controlled>

Tip: avoid toFieldValue={(x) => Boolean(x)} for stringly booleans — Boolean("false") is true. Let the Switch pass real booleans, or map explicitly.

For combobox-style controls:

<Controlled
  name="country"
  label="Country"
  valueProp="value"
  changeProp="onValueChange"
  emptyValue=""
>
  <SelectRoot>...</SelectRoot>
</Controlled>

Normalizing values: toFieldValue and fromFieldValue

Components disagree on what they emit:

  • Native inputs: a change event with target.value
  • Headless UI: the next value directly

A default toFieldValue can detect events and extract target.value; everything else passes through unchanged. Use fromFieldValue when the form stores a number but the UI needs a string (or the opposite).

Set emptyValue when "no value" should display as "" or false instead of undefined, so controlled children do not flip between controlled and uncontrolled modes.

Labels, errors, and IDs

Good form UX batches concerns:

  • Stable id for label htmlFor (from the child or name + useId())
  • Error styling on the label when fieldState.error is set
  • FormMessage (or equivalent) under the control

Keeping that in the wrapper means product code stays declarative: name, label, children.

Forced values (sync UI and submit state)

Sometimes the UI must show a value that did not come from user typing — for example, prefilling from an API or driving a field from another control. A forcedValue prop can:

  • Display that value in the child
  • Sync it into react-hook-form with onChange(..., { shouldValidate: false }) so submit payloads stay consistent without firing validation on every sync tick

Mental model

Think of your wrapper as a protocol translator:

react-hook-form field  →  [valueProp, changeProp, onBlur, ref]  →  Your component

Controller already defines the left side. Your job on the right is naming props and normalizing events — once, in one place — instead of in every form screen.

When to reach for something heavier

This pattern shines for app forms with mixed primitives and design-system widgets. Reach for dedicated field libraries or full form builders when you need schema-driven layouts, async server validation pipelines, or field arrays with complex nesting — but you can still keep the same Controlled adapter at the leaves.

Summary

  • Controlled inputs keep form state in React; they are worth the wiring for validation and resets.
  • react-hook-form's Controller is the core adapter; a Controlled wrapper removes repeated render boilerplate.
  • Use cloneElement for single-child components; use a render prop when you need more structure.
  • Configure valueProp / changeProp, emptyValue, and optional toFieldValue / fromFieldValue so any component speaks the same language as your form state.

Once that adapter exists, adding a new field type to your design system is a one-liner in the form — not a new copy-pasted Controller render block.


Rate this post

All fields are optional. Just stars is fine.

No ratings yet