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

Controlled vs. Uncontrolled Components and When React.memo Actually Stops a Re-render

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

Misunderstanding the controlled/uncontrolled boundary leads to forms that either lose input or fight the DOM. Misunderstanding memo's shallow comparison leads developers to wrap components in memo, see no improvement, and conclude React optimization is broken when the real problem is unstable prop references.

Summary

A controlled input runs a tight data loop: a keystroke updates the DOM, fires onChange, writes to React state, schedules a re-render, and then React flushes the DOM again with its own state. The framework never trusts the browser; state is the sole authority and the DOM is just a projection. Uncontrolled components flip this by handing a ref to the real DOM node and reading values directly, which suits submit-time workflows where real-time validation isn't needed.

Multi-field forms expose the trade-off. A single useState object plus one dynamic handleChange can manage ten fields, whereas the uncontrolled approach demands a separate useRef for every field, scaling poorly. The LoginForm demo layers validation on top, deriving an isValid boolean from form and error state rather than storing it separately, which eliminates synchronization bugs.

React.memo wraps a component in a shallow comparison: if every prop passes a strict-equality check, the render is skipped. The catch is that objects, arrays, and functions created inline during a parent render get fresh references every time, so memo silently fails. That failure is the entire reason useCallback and useMemo exist.

Takeaways
Controlled components overwrite the DOM's value attribute with React state on every render, treating state as the single source of truth.
Uncontrolled components use useRef to grab the real DOM node and read values directly, with React never participating in value updates.
A single useState object plus a dynamic key handler scales to many form fields; the uncontrolled equivalent requires one useRef per field.
Deriving isValid from existing state instead of storing it as separate state avoids synchronization bugs across multiple change sources.
React.memo performs a shallow (===) comparison on props and skips the render when nothing changed.
Passing inline objects, arrays, or functions as props creates new references every render, making memo's comparison always fail.
Functional state updates (prev => ...) protect against stale closures during rapid input sequences.
Conclusions

The controlled pattern's 'React doesn't trust the DOM' framing explains why React feels heavy for simple forms: it reasserts control on every keystroke, even when the DOM already has the right value.

Choosing between controlled and uncontrolled is really a question of when you need the value. Real-time validation, formatting, or field linkage demands controlled; submit-and-forget forms work fine uncontrolled.

The isValid derivation pattern is under-taught. Many developers reach for useEffect to sync a separate isValid state, creating a second source of truth and the bugs that come with it.

React.memo's failure mode is silent and confusing. A component wrapped in memo that still re-renders on every parent render looks like a React bug, but it is almost always unstable prop references from inline functions or objects.

Concepts & terms
SyntheticEvent
React's cross-browser wrapper around native DOM events. It normalizes event behavior and is pooled for performance, meaning its properties are nullified after the event callback returns unless you call e.persist().
Shallow comparison
A check that compares two values using === for primitives, or compares the first level of properties by reference for objects. React.memo uses it on props; if every prop is referentially identical to the previous render, the component skips re-rendering.
Barrel export
A single index.js file that re-exports everything from multiple modules in a directory, letting consumers import from one path instead of knowing internal file structure.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗