Controlled vs Uncontrolled Inputs
In React 19, you don't need to write fully controlled inputs manually for most native fields. But controlled patterns (especially via Controller) are still recommended when working with modern UI libraries or rich interactive forms.
React Hook Form embraces uncontrolled components but is fully compatible with controlled components.
Most UI libraries are built only for controlled mode, so mixing is the standard way to get the best of both worlds:
Performance from uncontrolled native fields
Flexibility from controlled UI library components
Controlled - React state is the single source of truth. Every keystroke updates state and re-renders.
function ControlledInput () {
const [ email , setEmail ] = useState ( "" );
return (
< input
type = "email"
value = {email}
onChange = {( e ) => setEmail (e.target.value)}
/>
);
}
Uncontrolled - The DOM holds the value. You read it on submit (via ref or FormData).
function UncontrolledInput () {
const ref = useRef < HTMLInputElement >( null );
function handleSubmit ( e : React . FormEvent ) {
e. preventDefault ();
console. log (ref.current?.value);
}
return (
< form onSubmit = {handleSubmit}>
< input type = "email" ref = {ref} defaultValue = "" />
< button type = "submit" >Submit</ button >
</ form >
);
}
The modern default for simple forms - no state, no refs, no re-renders.
async function subscribe ( formData : FormData ) {
"use server" ;
const email = formData. get ( "email" );
await saveSubscriber (email);
}
export function NewsletterForm () {
return (
< form action = {subscribe}>
< input name = "email" type = "email" required />
< button type = "submit" >Subscribe</ button >
</ form >
);
}
register() wires inputs as uncontrolled - fast, minimal re-renders.
import { useForm } from "react-hook-form" ;
type FormValues = { email : string ; password : string };
export function LoginForm () {
const { register , handleSubmit } = useForm < FormValues >();
return (
< form onSubmit = { handleSubmit (( data ) => console. log (data))}>
< input { ... register ( "email" )} type = "email" />
< input { ... register ( "password" )} type = "password" />
< button type = "submit" >Log in</ button >
</ form >
);
}
Use Controller to bridge to controlled components like shadcn/ui Select or MUI.
import { Controller, useForm } from "react-hook-form" ;
import { Select, SelectTrigger, SelectContent, SelectItem } from "@/components/ui/select" ;
export function PlanForm () {
const { control , handleSubmit } = useForm ({ defaultValues: { plan: "free" } });
return (
< form onSubmit = { handleSubmit (( data ) => console. log (data))}>
< Controller
name = "plan"
control = {control}
render = {({ field }) => (
< Select value = {field.value} onValueChange = {field.onChange}>
< SelectTrigger />
< SelectContent >
< SelectItem value = "free" >Free</ SelectItem >
< SelectItem value = "pro" >Pro</ SelectItem >
</ SelectContent >
</ Select >
)}
/>
< button type = "submit" >Save</ button >
</ form >
);
}
Most native <input>, <textarea>, <select> → uncontrolled with register() or server actions
Custom or third-party components (shadcn/ui, MUI, etc.) → controlled with <Controller>
Need real-time logic on some fields, performance on others → mix as needed
Never switch a single input from controlled to uncontrolled (or vice versa) during its lifetime - this triggers React warnings.
Do not manually add value + onChange to a field that uses register() - it breaks the uncontrolled pattern.
For the same field name, stick to one approach consistently.
Always provide defaultValue (not value) for uncontrolled inputs to avoid the controlled/uncontrolled warning.
The form is simple (login, contact, newsletter, basic settings - usually under 8 fields)
You mainly need the values only on submit
You are using server actions with <form action={...}>
You want minimal re-renders and the best possible performance
You plan to use useActionState and useFormStatus
Progressive enhancement matters (form should still work if JavaScript fails)
You are okay relying mostly on HTML5 validation plus server-side validation
You want the simplest code with automatic form reset on successful submission
There are no complex real-time UI updates or interdependent fields
You prefer less boilerplate and a smaller bundle size
The form has many fields (over 8–10) or is multi-step
You need real-time validation, formatting, or live feedback as the user types
Fields depend on each other (show/hide, enable/disable, or perform calculations)
You need to pre-populate the form with data from an API
You want instant client-side error messages with excellent UX
You have complex client-side logic before submission (password strength, totals, live previews)
You are using TypeScript and want strong type safety across the form
You need fine-grained control over UI updates
The form includes file uploads with preview or progress indicators
You want to keep most logic on the client before sending data to the server
Simple form + Server Actions → Uncontrolled inputs with useActionState and useFormStatus
Complex, multi-step, or rich UX → react-hook-form + zod (still uses uncontrolled inputs internally for performance)
Editing existing data → Usually react-hook-form with reset() or native requestFormReset
Maximum performance and minimal code → Native uncontrolled inputs with React 19 hooks
Heavy real-time interactivity → react-hook-form for the best balance of control and performance
Start with uncontrolled inputs + React 19 native hooks for any new simple or medium-sized form.
Switch to react-hook-form the moment you need real-time validation, conditional fields, or complex client-side logic.
Even when using react-hook-form, you still benefit from uncontrolled input performance under the hood.