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.
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:
- Resolves
controlfromFormProvideror an explicit prop - Delegates to
FormField/Controllerunder the hood - 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 style | Value prop | Change prop |
|---|---|---|
<input> | value | onChange |
| Switch | checked | onCheckedChange |
| Select / Combobox | value | onValueChange |
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
idforlabel htmlFor(from the child orname+useId()) - Error styling on the label when
fieldState.erroris 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
Controlleris the core adapter; aControlledwrapper removes repeated render boilerplate. - Use cloneElement for single-child components; use a render prop when you need more structure.
- Configure
valueProp/changeProp,emptyValue, and optionaltoFieldValue/fromFieldValueso 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.