跪拜 Guibai
← All articles
React.js · JavaScript · Frontend Framework

The Controlled vs. Uncontrolled Split That Breaks React Forms Silently

By 烬羽 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

A form that silently computes the wrong disabled state for a submit button ships a broken UX that no stack trace will reveal. As React 18 concurrent features land in more codebases, closure-based state reads that worked under synchronous batching become intermittent time bombs.

Summary

A single `handleChange` in a login form uses `setForm({...form})` for one state and `setErrors(prev => ({...prev}))` for another. The direct spread reads a closure snapshot that may be stale under concurrent rendering, while the functional updater always receives the latest state. This inconsistency is not a style preference — it is a silent logic breakpoint that corrupts validation-dependent UI like submit-button disabled states.

An experiment demonstrates the gap: two rapid `setForm` calls using direct spread both read the initial value, so the first update is lost. The functional version chains correctly. The bug never throws; it only surfaces as an intermittently wrong button state that vanishes when a `console.log` is added or input order changes.

The underlying design question is whether React needs to know a value on every keystroke. Controlled components make React state the single source of truth and demand functional updates to protect that truth from concurrency. Uncontrolled components leave truth in the DOM and read it only on demand. The choice must be explicit and consistent — React's console warning against mixing the two is a deliberate enforcement of that clarity.

Takeaways
Direct state spreads like `setForm({...form})` read a closure snapshot, not necessarily the latest state under React 18's concurrent mode.
Functional updates (`prev => ({...prev})`) guarantee the most recent state, making them the safe default when the new state depends on the old.
An inconsistent update pattern inside the same handler — one direct, one functional — creates a logic split that corrupts derived UI like button disabled states.
The bug produces no errors; it manifests as an intermittently wrong button state that disappears when logging or input order changes.
Controlled components require React to know the value on every keystroke; uncontrolled components leave truth in the DOM and read it only on demand.
React's console warning against mixing controlled and uncontrolled is a deliberate design choice to force explicit data-flow decisions.
Conclusions

The real danger of stale-closure bugs in React 18 is not that they crash — it is that they corrupt derived state just enough to ship a broken interaction that passes manual QA.

React's controlled/uncontrolled dichotomy is less about two APIs and more about forcing developers to declare where the single source of truth lives, because implicit dual ownership is the root of the bug class.

The article's experiment shows that two adjacent `setState` calls in the same synchronous block can read different snapshots of the same state variable, which contradicts the mental model most developers carry from React 17.

Concepts & terms
Controlled component
A form element whose value is driven by React state via the `value` prop and an `onChange` handler. React is the single source of truth; every keystroke triggers a state update and re-render.
Uncontrolled component
A form element that manages its own internal state in the DOM. React reads the value only when needed (e.g., via a ref on submit) rather than intercepting every keystroke.
Functional update
Passing a function `prev => nextState` to `setState` instead of a value. React guarantees `prev` is the most recent committed state, making it safe when the new state depends on the old, especially under concurrent rendering.
Closure snapshot (stale closure)
A variable captured by a closure that reflects the value at the time the closure was created, not the current value. In React, event handlers and effects can read stale state if they close over a previous render's state value.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗