Uncontrolled Components
An uncontrolled component is a form element whose value is managed by the DOM itself rather than by React state. Instead of wiring every keystroke through useState and an onChange handler, you let the browser’s native input behavior run as usual, and you reach into the DOM with a ref only when you actually need the value — typically on form submit. This is closer to how forms worked before React, and it trades some control for simplicity and, in a few cases, is the only option available.
Overview: Controlled vs. Uncontrolled
In a controlled component, React state is the single source of truth: you set value={state} and update that state on every onChange, so React re-renders the input on every keystroke to reflect the current state. In an uncontrolled component, the DOM node holds its own value internally, exactly like a plain HTML form. React does not track each keystroke and does not re-render as the user types. You give the input an initial value with defaultValue (or defaultChecked for checkboxes and radios), attach a ref with useRef, and read ref.current.value whenever you need the current contents — usually inside a submit handler.
This matters for the render cycle: with a controlled input, typing triggers setState → re-render → reconciliation → commit, on every character. With an uncontrolled input, typing only touches the browser’s internal DOM state; no React render happens at all until something else causes one. That makes uncontrolled inputs cheaper for large, rarely-validated forms, but it also means React is out of the loop — you cannot easily derive other UI (like a live character counter or instant validation message) from the value without also tracking it in state, which partly defeats the point.
Refs themselves matter here too. A ref created with useRef(null) is attached to the actual DOM node only after React commits the render to the DOM. That’s why you can safely read inputRef.current.value inside an event handler (which fires after mount) but not during the component’s render body (where inputRef.current may still be null).
Syntax
import { useRef } from "react";
function MyForm() {
const inputRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
console.log(inputRef.current.value);
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="Hello" />
<button type="submit">Submit</button>
</form>
);
}
| Piece | Purpose |
|---|---|
useRef(null) |
Creates a mutable ref object; .current starts as null and is set to the DOM node after mount. |
ref={inputRef} |
Attaches the ref to the rendered <input> DOM element. |
defaultValue |
Sets the input’s initial value only; the DOM then owns subsequent changes. Use defaultChecked for checkboxes/radios. |
inputRef.current.value |
Reads the current DOM value on demand, e.g. inside an event handler. |
Examples
Example 1: A basic uncontrolled text input
import { useRef } from "react";
function NameForm() {
const nameInputRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
alert(`Submitted name: ${nameInputRef.current.value}`);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" type="text" ref={nameInputRef} defaultValue="Guest" />
<button type="submit">Submit</button>
</form>
);
}
export default NameForm;
Renders: a text input pre-filled with “Guest” and a Submit button. As the user types, React does not re-render at all — the browser handles the input natively. Only when the form is submitted does the handler read the live value out of the DOM via nameInputRef.current.value.
Output: alert("Submitted name: Guest") if the field is left unchanged
Example 2: File inputs (a case where uncontrolled is required)
import { useRef, useState } from "react";
function AvatarUpload() {
const fileInputRef = useRef(null);
const [fileName, setFileName] = useState("");
function handleUpload(e) {
e.preventDefault();
const file = fileInputRef.current.files[0];
if (file) {
setFileName(file.name);
}
}
return (
<form onSubmit={handleUpload}>
<input type="file" ref={fileInputRef} accept="image/*" />
<button type="submit">Upload</button>
{fileName && <p>Selected file: {fileName}</p>}
</form>
);
}
export default AvatarUpload;
Renders: a native file picker and Upload button. A <input type="file"> value is controlled entirely by the browser for security reasons — you cannot set it programmatically with value, so React file inputs are always uncontrolled. The component reads the chosen file from fileInputRef.current.files on submit, then stores just the derived file name in state to display it.
Example 3: A whole form read with FormData
import { useRef } from "react";
function SignupForm() {
const formRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(formRef.current);
const values = Object.fromEntries(data.entries());
console.log(values);
}
return (
<form ref={formRef} onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" defaultValue="" />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" defaultValue="" />
<label htmlFor="plan">Plan</label>
<select id="plan" name="plan" defaultValue="free">
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
<button type="submit">Sign up</button>
</form>
);
}
export default SignupForm;
Renders: a signup form with email, password, and a plan dropdown. Instead of a ref per field, one ref on the <form> element lets the built-in FormData API collect every named field at once, keyed by each input’s name attribute. This scales well to large forms without a ref for every field.
Output: console.log({ email: "user@example.com", password: "secret123", plan: "free" })
How It Works Step by Step
On mount: React renders the JSX, creates the real DOM node with whatever defaultValue/defaultChecked you provided, commits it to the page, and then attaches the ref by setting ref.current to that DOM node. From this point, ref.current is a live handle to the actual input element.
While the user types: the browser updates the input’s internal value directly. No setState is called, so no re-render, no reconciliation, and no diffing happen — React is entirely uninvolved until you ask it to look.
On read (e.g. submit): your event handler runs after the native event fires, so ref.current is guaranteed to be attached; ref.current.value returns whatever the DOM currently holds, which may differ completely from the defaultValue you originally passed in.
On unmount: React removes the DOM node during the commit phase, and the ref is reset to null. Any value the user typed is gone unless you read it out beforehand.
Common Mistakes
Mistake 1: Reading a ref during render, before it’s attached.
function BadInput() {
const inputRef = useRef(null);
console.log(inputRef.current.value); // TypeError: Cannot read properties of null
return <input ref={inputRef} defaultValue="Hi" />;
}
During the first render, inputRef.current is still null because the DOM node hasn’t been created yet. Only read ref.current inside effects or event handlers, which run after commit — never in the component body.
function GoodInput() {
const inputRef = useRef(null);
function handleClick() {
console.log(inputRef.current.value); // safe: runs after mount
}
return (
<>
<input ref={inputRef} defaultValue="Hi" />
<button onClick={handleClick}>Log value</button>
</>
);
}
Mistake 2: Passing value without onChange, expecting uncontrolled behavior.
function BadLockedInput() {
return <input type="text" value="locked" />;
}
Giving an input a value prop makes it controlled, and React expects you to update that value via state on every change. Without an onChange handler, React logs a warning (“You provided a value prop to a form field without an onChange handler”) and the field becomes effectively frozen, since nothing ever changes the prop. If you want an initial value the user can freely edit, use defaultValue instead, which only seeds the DOM once and never fights the user’s typing.
function GoodUnlockedInput() {
return <input type="text" defaultValue="locked" />;
}
Best Practices
- Use uncontrolled components for simple, large, or one-time-submit forms where you don’t need per-keystroke validation or derived UI.
- Use file inputs uncontrolled always — the browser will not let React set their value directly.
- Prefer
FormDataplus one ref on the<form>over a separateuseReffor every field when a form has many inputs. - Never mix an input between controlled and uncontrolled during its lifetime (e.g. going from
value={undefined}tovalue={"text"}); React will warn about a component changing from uncontrolled to controlled. - If you need live validation, character counts, or conditional rendering based on what’s typed, use a controlled component instead — uncontrolled inputs can’t drive that without adding state anyway.
- To reset an uncontrolled input’s displayed value, don’t try to mutate the DOM manually; instead change the element’s
keyprop so React unmounts and remounts it fresh with a newdefaultValue.
Practice Exercises
- Build an uncontrolled login form with email and password inputs. On submit, read both values with refs and log them to the console instead of using state.
- Build an uncontrolled file input that previews the selected image using
URL.createObjectURL(file)in an<img>tag, only updating state with the resulting preview URL, never the file input’s value itself. - Take the uncontrolled
NameFormexample from this lesson and convert it into a controlled component usinguseStateandonChange. Compare how many times each version re-renders while typing.
Summary
- Uncontrolled components let the DOM manage form values internally; React only reads them on demand via a
ref. defaultValue/defaultCheckedset the initial value only and never fight the user’s edits.- Typing in an uncontrolled input causes no re-render, since no React state changes.
- A ref’s
.currentisnulluntil after the DOM commits — only read it in effects or event handlers, not during render. - File inputs must always be uncontrolled; use
ref.current.filesto access selected files. FormDatapaired with a single form ref is an efficient way to read many uncontrolled fields at once.- Never mix
valueanddefaultValuesemantics on the same input across renders — pick controlled or uncontrolled and stay consistent.
