React Form Data Flow: When State Should Own the Input and When the DOM Should
React Controlled vs Uncontrolled Components: Explaining Form Data Flow from a Single Input Box
When working with forms, we often encounter two seemingly similar approaches: one binds value and onChange to the input box, while the other only attaches a ref and reads the content upon submission.
Both approaches can capture user input, but the underlying data flow is completely different. The real question to ask is not "which approach is more advanced," but rather: Is the current value of the input box managed by React state, or by the DOM itself?
This article uses a React 19 form example, starting from a single input box, to progressively explain controlled components, uncontrolled components, multi-field forms, and real-time validation.
1. First, Grasp the Core: Who is the Single Source of Truth
In form scenarios, the two types of components can be distinguished in one sentence:
- Controlled components: Form values are stored in React state; what the page displays is determined by state.
- Uncontrolled components: Form values are stored in DOM nodes; React reads them via
refwhen needed.
Their input processes can be simplified as:
Controlled component: User input -> onChange -> Update state -> React re-renders -> value updates
Uncontrolled component: User input -> DOM updates value itself -> Click submit -> Read via ref
This distinction further affects real-time validation, button states, field linkage, reset methods, and component rendering behavior.
2. Controlled Components: Input Values Always Pass Through React
Let's look at the controlled input box in the example:
import { useState } from 'react'
function ControlledInput() {
const [value, setValue] = useState('')
return (
<>
<label htmlFor="nickname">Nickname</label>
<input
id="nickname"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
<p>Current input: {value || 'Empty string'}</p>
</>
)
}
There are two key attributes here:
value={value}: The content displayed in the input box comes from React state.onChange={...}: Each time the user types, the latest content is written back to state.
Suppose the user types the letter A. The process is as follows:
- The browser triggers
onChange. Ais obtained viaevent.target.value.setValue('A')updates the state.- The component re-renders.
- The new
value="A"is passed back to the input box.
Therefore, React state is the single source of truth. We can display this value in the input box and also use it to synchronously update preview areas, remaining character counts, or submit buttons.
Why Setting Only value Doesn't Work
If you set value on an input box but don't update it in onChange:
<input value={value} />
Although the user types content on the keyboard, on the next render, React will still pass the old value to the input box. The result is that the input box appears uneditable.
Therefore, controlled input boxes typically come in pairs: value + onChange, while checkboxes typically use checked + onChange.
3. Uncontrolled Components: Let the DOM Store the Current Value
Now look at the uncontrolled input box:
import { useRef, useState } from 'react'
function UncontrolledInput() {
const inputRef = useRef(null)
const [submittedValue, setSubmittedValue] = useState('')
const handleRead = () => {
setSubmittedValue(inputRef.current.value)
}
return (
<>
<label htmlFor="message">Message</label>
<input id="message" ref={inputRef} />
<button type="button" onClick={handleRead}>
Read DOM value
</button>
<p>This read: {submittedValue || 'Not yet read'}</p>
</>
)
}
Here, no value is bound to the input box. After the user types, the content is directly stored in the DOM node. inputRef.current points to the real <input>, so the value can be read when the button is clicked:
inputRef.current.value
It's important to note: useRef stores a reference, but modifying ref.current does not trigger a component re-render.
During input, the DOM value is constantly changing, but React does not re-execute the component function because of this. The submittedValue in the example is only used to display "the last read result" on the page; it is not responsible for controlling the input box.
How to Set an Initial Value
If an uncontrolled component needs an initial value, use defaultValue:
<input ref={inputRef} defaultValue="Default nickname" />
defaultValue is only responsible for the initial content; subsequent input is still managed by the DOM. If you use value instead, the component enters controlled mode.
4. Comparing the Two Approaches Side by Side
| Comparison Item | Controlled Component | Uncontrolled Component |
|---|---|---|
| Data storage location | React state | DOM node |
| Reading method | Read state directly | Via ref.current.value |
| Updates state on input | Yes | No |
| Real-time validation | Easy to implement | Usually handled on read or submit |
| Field linkage | Easy to implement | Requires manual reading and syncing |
| Resetting content | Update state | Manipulate DOM or reset native form |
| Integration with non-React code | Requires state synchronization | Usually more direct |
| Typical scenarios | Login, registration, search filters, dynamic forms | Simple submissions, file selection, integrating legacy pages |
There is no absolute superiority here. Controlled components provide a clearer data flow, while uncontrolled components reduce state synchronization during input.
5. How Multiple Fields Share a Single Change Handler
Real business forms usually have more than one field. The registration form in the example uses a single object to manage username and password uniformly:
const initialForm = {
username: '',
password: '',
}
function RegisterForm() {
const [form, setForm] = useState(initialForm)
const handleChange = (event) => {
const { name, value } = event.target
setForm((previousForm) => ({
...previousForm,
[name]: value,
}))
}
return (
<form>
<input
name="username"
value={form.username}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
</form>
)
}
The key is that the name of each input box matches the field name in state:
[name]: value
This is JavaScript's computed property name. When the username input box changes, name is username; when the password input box changes, name is password. This way, you don't need to write a separate handler function for each field.
A functional update is used here:
setForm((previousForm) => ({
...previousForm,
[name]: value,
}))
It explicitly means "generate the next state based on the previous state," which is safer during consecutive updates or when logic continues to expand.
6. Why Controlled Forms Are Suitable for Real-Time Validation
Because the input content is already in state, we can validate immediately after each change:
function validate(form) {
const errors = {}
if (!form.username.trim()) {
errors.username = 'Please enter a username'
} else if (form.username.trim().length < 3) {
errors.username = 'Username must be at least 3 characters'
}
if (!form.password) {
errors.password = 'Please enter a password'
} else if (form.password.length < 6) {
errors.password = 'Password must be at least 6 characters'
}
return errors
}
Calculate the next form and error information during input:
const handleChange = (event) => {
const { name, value } = event.target
const nextForm = { ...form, [name]: value }
setForm(nextForm)
setErrors(validate(nextForm))
}
Perform a full validation again on submit:
const handleSubmit = (event) => {
event.preventDefault()
const nextErrors = validate(form)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
console.log('Submit form', form)
}
Finally, the validation result can also control the button state:
const isValid = Object.keys(validate(form)).length === 0
<button type="submit" disabled={!isValid}>
Submit Registration
</button>
This is the main value of controlled components: the same state simultaneously drives the input boxes, error messages, button states, and final submission data, making page behavior easier to reason about.
7. Don't Switch Back and Forth Between Controlled and Uncontrolled
The following approach is prone to problems:
const [value, setValue] = useState()
<input value={value} onChange={(e) => setValue(e.target.value)} />
The initial value is undefined, so the input box starts as effectively uncontrolled; after the user types, value becomes a string, and it switches to a controlled component.
If you plan to use a controlled input box, the initial value should also be a valid value of the corresponding type:
const [value, setValue] = useState('')
For checkboxes, you can use a boolean value:
const [agreed, setAgreed] = useState(false)
<input
type="checkbox"
checked={agreed}
onChange={(event) => setAgreed(event.target.checked)}
/>
8. File Input Boxes Are a Typical Exception
For security reasons, browsers do not allow applications to arbitrarily set the value of a file input box. Therefore, file inputs are typically read in an uncontrolled manner:
function FilePicker() {
const fileRef = useRef(null)
const handleSubmit = () => {
const file = fileRef.current.files[0]
console.log(file)
}
return (
<>
<input ref={fileRef} type="file" />
<button type="button" onClick={handleSubmit}>
Upload
</button>
</>
)
}
This also illustrates that real projects don't need to be forced into uniformity: the same form can have text fields controlled while reading files via ref.
9. How to Choose in Actual Development
Prioritize controlled components in the following situations:
- Validate and display errors during input.
- One field affects another field.
- Disable buttons or switch interface states based on content.
- Implement formatting, character counting, search suggestions, or real-time previews.
- Form data needs to be passed to other components or business logic at any time.
Consider uncontrolled components in the following situations:
- Only read the value once upon submission.
- The form is very simple and doesn't need real-time feedback.
- Need to integrate third-party code that depends on the real DOM.
- Using file input boxes.
- Gradually migrating old native forms to React.
There's also a practical rule of thumb: if you frequently write ref.current.value and then sync the result into state, it's likely clearer to just use a controlled component directly.
10. Summary
The essence of controlled vs. uncontrolled components lies not in whether Hooks are used, but in who stores and determines the current value.
value + onChange + stateconstitutes a controlled data flow.ref + DOM valueconstitutes an uncontrolled data flow.- Controlled components are suitable for real-time validation, field linkage, and state-driven interfaces.
- Uncontrolled components are suitable for simple reads, DOM integration, and file inputs.
- Multi-field forms can use
nameand computed property names to reuse update logic. - Once you choose the controlled mode, you should keep the type stable from the initial render.
There is no fixed answer for form approaches. First, confirm when the data needs to be read and whether it participates in interface calculations, then decide whether to let React manage it or let the DOM temporarily hold it.