Every input on a page holds a value. The question this chapter answers is: who is the source of truth for that value? Either React state owns it and the DOM just displays it (controlled), or the DOM owns it and React reads it when needed (uncontrolled). Both are valid. Mixing them carelessly inside one form is where bugs come from.
The problem: a form with two sources of truth
Here is a feedback form I have seen variations of many times. Name and email are kept in state. The message field, added later by someone else, is read through a ref.
import { useRef, useState, type FormEvent } from "react";
export function FeedbackForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const messageRef = useRef<HTMLTextAreaElement>(null);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name) {
alert("Name required");
return;
}
if (!email.includes("@")) {
alert("Valid email required");
return;
}
if (!messageRef.current?.value) {
messageRef.current?.focus();
return;
}
console.log("Submitted", { name, email, message: messageRef.current.value });
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Name" />
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="Email" />
<textarea ref={messageRef} placeholder="Your message" />
<button type="submit">Send Feedback</button>
</form>
);
}
It works, but look at what it costs. Resetting the form needs two different mechanisms: setName("") for some fields and messageRef.current.value = "" for another. You cannot show a live character count for the message without converting it. Validation reports errors in two different ways, an alert for two fields and a focus for the third. And anyone reading the code has to figure out, field by field, where the value actually lives. The form is not wrong, it is inconsistent, and inconsistency is what makes the next change risky.
The pattern: pick one owner per form
Before refactoring, it helps to be precise about the two tools.
State vs refs
useState holds a value React tracks. Changing it re-renders the component, so the UI always reflects it. useRef holds a mutable object whose .current survives renders, but changing it does not re-render anything.
import { useRef, useState } from "react";
export function RenderDemo() {
const [stateCount, setStateCount] = useState(0);
const refCount = useRef(0);
return (
<div>
<p>State count: {stateCount}</p>
<p>Ref count (stale until next render): {refCount.current}</p>
<button onClick={() => setStateCount((count) => count + 1)}>Increment state</button>
<button onClick={() => { refCount.current += 1; }}>Increment ref</button>
</div>
);
}
Click "Increment ref" five times and nothing changes on screen. Click "Increment state" once and the ref count jumps to 5, because the re-render finally read it. That is the whole difference: state is for values the UI shows, refs are for values the UI does not need to react to, and for handles to DOM nodes (focus, scroll, measure).
With that in mind, the two patterns are:
- Controlled component: the input's
valuecomes from state, andonChangeupdates that state. React is the single source of truth. You get instant validation, derived UI (counters, disabled buttons), and programmatic reset for free. - Uncontrolled component: the input keeps its own value in the DOM. You set an initial
defaultValueand read the current value when you need it, through a ref or throughFormDataon submit. Less code, fewer re-renders, but React does not "know" the value between reads.
Refactor 1: fully controlled
One state object holds the whole form. Refs are still used, but only for what refs are good at: moving focus.
import { useRef, useState, type ChangeEvent, type FormEvent } from "react";
type Feedback = { name: string; email: string; message: string };
type FieldName = keyof Feedback;
const emptyFeedback: Feedback = { name: "", email: "", message: "" };
function validate(feedback: Feedback): FieldName | null {
if (!feedback.name.trim()) return "name";
if (!feedback.email.includes("@")) return "email";
if (!feedback.message.trim()) return "message";
return null;
}
export function ControlledFeedbackForm() {
const [feedback, setFeedback] = useState<Feedback>(emptyFeedback);
const [invalidField, setInvalidField] = useState<FieldName | null>(null);
const fieldRefs = useRef<Partial<Record<FieldName, HTMLInputElement | HTMLTextAreaElement | null>>>({});
function handleChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = event.target;
setFeedback((current) => ({ ...current, [name]: value }));
setInvalidField(null);
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const firstInvalid = validate(feedback);
if (firstInvalid) {
setInvalidField(firstInvalid);
fieldRefs.current[firstInvalid]?.focus();
return;
}
console.log("Submitted", feedback);
setFeedback(emptyFeedback);
}
return (
<form onSubmit={handleSubmit} noValidate>
<input
name="name"
ref={(node) => { fieldRefs.current.name = node; }}
value={feedback.name}
onChange={handleChange}
aria-invalid={invalidField === "name"}
placeholder="Name"
/>
<input
name="email"
type="email"
ref={(node) => { fieldRefs.current.email = node; }}
value={feedback.email}
onChange={handleChange}
aria-invalid={invalidField === "email"}
placeholder="Email"
/>
<textarea
name="message"
ref={(node) => { fieldRefs.current.message = node; }}
value={feedback.message}
onChange={handleChange}
aria-invalid={invalidField === "message"}
placeholder="Your message"
/>
<p>{feedback.message.length} / 500</p>
<button type="submit" disabled={feedback.message.length > 500}>
Send Feedback
</button>
</form>
);
}
Notice what became easy: the character counter and the disabled button are one line each, validation is a pure function you can unit test, and reset is setFeedback(emptyFeedback). The name attribute on each field doubles as the key into the state object, so one handleChange serves every input.
Refactor 2: fully uncontrolled
Now the opposite direction. No state for values at all; the DOM holds them and we read them on submit.
import { useRef, type FormEvent } from "react";
export function UncontrolledFeedbackForm() {
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const messageRef = useRef<HTMLTextAreaElement>(null);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = nameRef.current?.value ?? "";
const email = emailRef.current?.value ?? "";
const message = messageRef.current?.value ?? "";
if (!name.trim()) return nameRef.current?.focus();
if (!email.includes("@")) return emailRef.current?.focus();
if (!message.trim()) return messageRef.current?.focus();
console.log("Submitted", { name, email, message });
event.currentTarget.reset();
}
return (
<form onSubmit={handleSubmit} noValidate>
<input ref={nameRef} placeholder="Name" />
<input ref={emailRef} type="email" placeholder="Email" />
<textarea ref={messageRef} placeholder="Your message" />
<button type="submit">Send Feedback</button>
</form>
);
}
You can go one step further and drop the refs, letting the browser's FormData API collect values by name:
import type { FormEvent } from "react";
export function UncontrolledSignupForm() {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const username = String(formData.get("username") ?? "");
const email = String(formData.get("email") ?? "");
console.log("Signup", { username, email });
}
return (
<form onSubmit={handleSubmit}>
<input name="username" required placeholder="Username" />
<input name="email" type="email" required placeholder="Email" />
<button type="submit">Submit</button>
</form>
);
}
This version re-renders zero times while the user types, and the browser's built-in validation (required, type="email") does the simple checks. For a login or newsletter form, that is often all you need.
A second example: a component that supports both modes
The pattern matters even more when you build reusable components. Native inputs let the caller choose: pass value and it is controlled, pass defaultValue and it is uncontrolled. Good design-system components do the same. Here is a quantity stepper that works either way.
import { useState } from "react";
function useControllableState<T>(controlledValue: T | undefined, defaultValue: T, onChange?: (next: T) => void) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
function setValue(next: T) {
if (!isControlled) setInternalValue(next);
onChange?.(next);
}
return [value, setValue] as const;
}
type QuantityStepperProps = {
value?: number;
defaultValue?: number;
onChange?: (quantity: number) => void;
min?: number;
max?: number;
};
export function QuantityStepper({ value, defaultValue = 1, onChange, min = 1, max = 99 }: QuantityStepperProps) {
const [quantity, setQuantity] = useControllableState(value, defaultValue, onChange);
return (
<div role="group" aria-label="Quantity">
<button onClick={() => setQuantity(Math.max(min, quantity - 1))} disabled={quantity <= min}>
-
</button>
<output>{quantity}</output>
<button onClick={() => setQuantity(Math.min(max, quantity + 1))} disabled={quantity >= max}>
+
</button>
</div>
);
}
import { useState } from "react";
import { QuantityStepper } from "./QuantityStepper";
export function CartLine() {
const [quantity, setQuantity] = useState(2);
return (
<>
<QuantityStepper defaultValue={1} />
<QuantityStepper value={quantity} onChange={setQuantity} max={5} />
<p>Subtotal: {quantity * 19}</p>
</>
);
}
The first stepper manages itself. The second is driven by its parent, which can clamp, sync with a cart store, or compute a subtotal. Libraries like Radix UI and Ant Design follow exactly this value / defaultValue / onChange convention, which is why it is worth learning to build. One rule: a component should not switch between controlled and uncontrolled during its lifetime; React warns about this for native inputs for a reason.
In 2026 / React 19
React 19 shifted the default for forms noticeably toward uncontrolled.
- Form Actions. You can pass a function to
<form action={...}>. React calls it with theFormDataon submit, wraps it in a transition, and, when the action succeeds, automatically resets uncontrolled fields. NopreventDefault, no refs. useActionStategives you the action's last result (errors, a success message) and anisPendingflag. Return the submitted values from the action and use them asdefaultValueto keep what the user typed when validation fails.useFormStatusfromreact-domlets a submit button deep inside the form readpendingwithout prop drilling.
import { useActionState } from "react";
type ContactState = { error: string | null; values: { email: string; message: string } };
async function sendContact(_previous: ContactState, formData: FormData): Promise<ContactState> {
const email = String(formData.get("email") ?? "");
const message = String(formData.get("message") ?? "");
if (!email.includes("@")) return { error: "Please enter a valid email", values: { email, message } };
await fetch("/api/contact", { method: "POST", body: formData });
return { error: null, values: { email: "", message: "" } };
}
const initialState: ContactState = { error: null, values: { email: "", message: "" } };
export function ContactForm() {
const [state, formAction, isPending] = useActionState(sendContact, initialState);
return (
<form action={formAction}>
<input name="email" type="email" defaultValue={state.values.email} placeholder="Email" />
<textarea name="message" defaultValue={state.values.message} placeholder="Message" />
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={isPending}>{isPending ? "Sending..." : "Send"}</button>
</form>
);
}
refis a regular prop. Function components receiverefdirectly, so wrapping a customTextFieldno longer needsforwardRef. Callback refs can also return a cleanup function.- Server Components. Controlled inputs need state, so they need
"use client". An uncontrolled form with a Server Function as its action can render from a Server Component and still work before JavaScript loads. - Form libraries. React Hook Form is uncontrolled at its core and registers inputs by ref, which is why it stays fast on huge forms. Libraries like TanStack Form lean controlled. Knowing this pattern tells you what you trade when picking one.
Controlled inputs are still the right tool for live feedback: search-as-you-type, formatted inputs (currency, phone), dependent fields, and anything the UI must reflect on every keystroke.
When to use which
Controlled fits when:
- The UI reacts to the value while typing (validation messages, counters, previews, enabling buttons).
- Values are formatted, masked or derived from other fields.
- The value must be set programmatically (reset to server data, "fill from profile").
Uncontrolled fits when:
- You only need the values on submit (login, contact, newsletter).
- The form is large and per-keystroke re-renders hurt.
- You use form Actions or a library like React Hook Form.
- You integrate non-React widgets that manage their own DOM (file inputs are always uncontrolled).
Common pitfalls
- Mixing modes in one form, as in the original example. Pick one owner per form, and use refs alongside controlled state only for imperative tasks like focus.
- Switching a single input between modes.
value={user?.name}starts asundefined(uncontrolled) and becomes a string (controlled), and React warns. Usevalue={user?.name ?? ""}. - Setting
valuewithoutonChange. The input becomes read-only. UsedefaultValueif you just want an initial value, or addreadOnlyif that is the intent. - Expecting a ref change to update the UI. Writing to
ref.currentnever re-renders. If the screen should show it, it belongs in state. - Expecting
defaultValueto follow prop changes. It is read only on mount. To reload an uncontrolled form with new data, change itskey.
Exercises
- Build a contact form (name, email, subject, message) three ways: controlled, uncontrolled with refs, and uncontrolled with
FormDataand no refs. - Write a short Markdown comparison table of the three versions: code style, validation, reset, readability and complexity.
- Rebuild the contact form with React 19
<form action>anduseActionState, keeping typed values when server validation fails. - Turn
QuantityStepperinto aTagInputthat supports bothvalueanddefaultValue, and verify both modes in a small demo page.
Video and source code
This chapter is adapted from Day 02 of tapaScript's "15 Days of React Design Patterns" by Tapas Adhikary, which walks through state vs refs and the feedback form refactor on video.