跪拜 Guibai
← Back to the summary

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

React Controlled/Uncontrolled Components and React.memo Performance Optimization — From Essence to Practice

Open the console, click a few buttons, and watch the log output. Only then can you truly understand "when React renders and when it doesn't."


Opening: Two Knowledge Threads, One Common Problem

Recently, I systematically reviewed the fundamentals of React forms and performance optimization, producing two sets of demos:

On the surface, these are two different things—form handling and rendering optimization. But at their core, they answer the same question:

Can this render be skipped? Or conversely—who really calls the shots between React's data and the DOM?

This article follows my own learning path, starting from the source code and breaking it down layer by layer.


1. Controlled Components: React is the Single Source of Truth

1.1 Code

// ControlledInput.jsx
// Controlled component (reactive state controls input)
// Collects user input
import { useState } from 'react';

function ControlledInput() {
  const [value, setValue] = useState('');
  return (
    <>
      ControlledInput
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
    </>
  );
}

1.2 What does onChange={(e) => setValue(e.target.value)} actually do?

This line of code is the soul of the entire controlled pattern. Let's break it down:

The key is here: after setValue is called, React schedules a re-render. During rendering, <input value={value}> uses the new value to set the DOM's value attribute again.

The complete data loop:

User types 'a'
  → Browser updates DOM value = 'a'
  → onChange fires
  → setValue('a') writes to React state
  → React schedules a re-render
  → <input value='a'> uses the state value to "flush" the DOM again
  → Screen displays 'a'

Essentially, React does not trust the DOM. Every render cycle, it overwrites the DOM value with its own state to ensure that what's displayed on screen always equals the value in state. This is the essence of "control" — state is the Single Source of Truth, and the DOM is merely its projection.

1.3 Why is it called "reactive"?

"Reactive state controls input" — this is the comment I wrote in the code. It has two layers of meaning:

  1. Reactive: useState is React's reactive primitive — when state changes, the view automatically follows. No manual DOM manipulation is needed.
  2. Control: The input's displayed value is entirely determined by state. User input is merely the "fuse" that triggers a state update.

2. Uncontrolled Components: The DOM Manages Itself

2.1 UncontrolledInput

// UncontrolledInput.jsx
// useRef
import { useRef } from 'react';

function UncontrolledInput() {
  const inputRef = useRef(null);
  const handleClick = () => {
    console.log(inputRef.current.value);
  };
  return (
    <>
      Uncontrolled Input
      <input type="text" ref={inputRef} />
      <button onClick={handleClick}>Get Input Value</button>
    </>
  );
}

2.2 What is useRef?

useRef(null) returns a plain JS object — { current: null }. Throughout the component's lifecycle, this object's reference stays the same, but .current can be changed freely.

Comparing it with useState side-by-side makes the difference very clear:

useState useRef
.current object reference A new one every render Always the same one
Does a value change trigger a re-render? Yes, immediately schedules No, silently updates
Suitable for storing Data that drives the UI DOM refs, timer IDs, previous values

ref={inputRef} this step: After rendering is complete, React directly stuffs the real <input> DOM node into inputRef.current. From then on, when you read inputRef.current.value, you're reading the current input value natively maintained by the browser — React does not participate in updating the value at all.

2.3 Uncontrolled Data Flow

User types 'a'
  → Browser updates DOM value = 'a'
  → React does nothing

When value is needed (button click)
  → inputRef.current.value → fetch directly from the DOM

The difference from controlled is clear at a glance: Controlled means React actively manages every frame; Uncontrolled means React lets go and only "fetches" from the DOM when needed.

2.4 CommentBox: Another Uncontrolled Scenario

// CommentBox.jsx
import { useRef } from 'react';

function CommentBox() {
  const textareaRef = useRef(null);
  const handleSubmit = () => {
    const comment = textareaRef.current.value;
    if (!comment) return;
    console.log(comment);
  };
  return (
    <>
      <textarea placeholder="Enter comment..." ref={textareaRef}></textarea>
      <button onClick={handleSubmit}>Submit Comment</button>
    </>
  );
}

Exactly the same pattern as UncontrolledInput — just <input> swapped for <textarea>, and the usage of useRef remains completely unchanged.

This reveals a pattern: Uncontrolled is particularly suited for "fetch value on submit" scenarios. No real-time validation needed, no formatting, no linkage — just grab it from the DOM at the last moment.


3. Multi-field Forms: Choosing Controlled vs Uncontrolled

3.1 RegisterForm — One useState Object Handles All Fields

// RegisterForm.jsx
// Uncontrolled would need two useRefs
// vue ref/reactive object two reactive APIs
import { useState } from 'react';

function RegisterForm() {
  const [form, setForm] = useState({
    username: "",
    password: ""
  });

  const handleChange = (e) => {
    setForm({
      ...form,
      [e.target.name]: e.target.value
    });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Register', form);
  };

  return (
    <div>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
        placeholder="Please enter username"
        type="text"
      />
      <button type="submit" onClick={handleSubmit}>Submit</button>
    </div>
  );
}

3.2 "Uncontrolled would need two useRefs" — Why not choose uncontrolled?

This is a note I wrote in the code. It means: If this form were written using the uncontrolled approach, it would need two useRefs — one for username, one for password.

Each field needs a ref to attach to the DOM, and fetching values requires reading them one by one. Two fields are manageable, but what about ten fields? The amount of code would explode linearly.

In contrast, the controlled approach only needs one useState object + one handleChange to handle everything:

setForm({
  ...form,                        // Spread old values, keep unchanged fields
  [e.target.name]: e.target.value  // Computed property name: only overwrite the changed field
});

[e.target.name] is parsed by the JS engine as a dynamic key name — when <input name="username"> triggers, it becomes { username: e.target.value }. One handleChange handles all fields.

3.3 "vue ref/reactive two reactive APIs" — Cross-framework analogy

This is also a note I wrote in the comments. Vue 3's reactivity has two sets of APIs:

Vue API Use Case React Counterpart
ref() Wraps primitive types, read/write via .value useState(single value)
reactive() Wraps objects, read/write directly via .property useState({...})

The idea of useState({ username: "", password: "" }) is equivalent to Vue's reactive() — using one reactive object to manage all form fields collectively, rather than creating a separate ref for each field.


4. LoginForm — Complete Controlled Pattern in Practice

4.1 Full Code

// LoginForm/index.jsx
import { useState } from 'react';
import './index.css';

function LoginForm() {
  const [form, setForm] = useState({
    username: "",
    password: ""
  });
  const [errors, setErrors] = useState({});

  const validate = (name, value) => {
    let msg = "";
    if (name === 'username') {
      if (!value) {
        msg = 'Username is empty';
      } else if (value.length < 3) {
        msg = 'Username must be at least 3 characters';
      }
    }
    if (name === 'password') {
      if (!value) {
        msg = 'Password cannot be empty';
      } else if (value.length < 6) {
        msg = 'Password must be at least 6 characters';
      }
    }
    setErrors(prev => ({
      ...prev,
      [name]: msg
    }));
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm({
      ...form,
      [name]: value
    });
    validate(name, value);
  };

  const isValid = form.username && form.password
    && !errors.username && !errors.password;

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!isValid) return;
    console.log(form, '----------');
  };

  return (
    <div className="login-wrapper">
      <form className="login-catd" onSubmit={handleSubmit}>
        <h2>Login</h2>
        <div className="form-item">
          <label>Username</label>
          <input type="text" name="username"
            value={form.username} onChange={handleChange} />
          {errors.username && (
            <span className="error">{errors.username}</span>
          )}
        </div>
        <div className="form-item">
          <label>Password</label>
          <input type="text" name="password"
            value={form.password} onChange={handleChange} />
          {errors.password && (
            <span className="error">{errors.password}</span>
          )}
        </div>
        <button type="submit" disabled={!isValid}>Login</button>
      </form>
    </div>
  );
}

4.2 Validation Mechanism: The Design of validate(name, value)

The validate function takes two parameters — field name and current value, returning the error message for that field:

validate("username", "")     → "Username is empty"
validate("username", "ab")   → "Username must be at least 3 characters"
validate("username", "dai")  → ""
validate("password", "123")  → "Password must be at least 6 characters"

A noteworthy detail is that setErrors uses functional updates:

setErrors(prev => ({
  ...prev,
  [name]: msg
}));

prev => ... receives the previous errors object. Using the functional form ensures each validation is based on the latest error state — this is a safe practice during rapid consecutive inputs, avoiding the stale closure trap.

4.3 isValid — A Computed Property, Not State

const isValid = form.username && form.password
  && !errors.username && !errors.password;

This is a key architectural choice: isValid is not an independent useState, but is computed in real-time from form and errors on every render. This avoids state redundancy — if isValid were an independent state, you'd need to synchronize it in three places (username changes, password changes, errors changes), easily leading to inconsistencies. Deriving it directly from the base state ensures it's always correct.

4.4 Complete Data Flow

User Input → handleChange
  ├─ setForm({...form, [name]: value})      // Update form value
  └─ validate(name, value)
       └─ setErrors(prev => ({...prev}))    // Update error state
            ↓
  form and errors change → App re-renders
            ↓
  isValid recalculated (derived value)
            ↓
  button disabled={!isValid}   ← Button responds automatically
  {errors.username && <span>}  ← Error message shows/hides automatically

The advantage of the controlled pattern is fully displayed here: one handleChange simultaneously drives both the form value and the validation logic, while isValid and error prompts are completely auto-derived from the base state — no manual synchronization needed.


5. React.memo — When Rendering Needs a Brake

Jumping from forms to performance optimization might seem abrupt, but the logic is coherent. In the forms section, we understood "state-driven rendering"; now the question is: Does every state change force the entire tree to re-render? Can we hit the brakes?

5.1 README Notes from the useCallback Project

# useCallback & useMemo
Hooks born for performance optimization

## Problem
- Parent component has multiple states, child component depends on some of them
- When parent re-renders, child component also re-renders
  Update
  Causes performance waste
  Hope to refuse re-rendering when unrelated properties change
  memo  memorize  please remember me
  Prop comparison

This note already clearly states the core conflict: When a parent component re-renders, even if the child component's props haven't changed, the child will also re-render by default. This is wasteful.

5.2 Code: A Comparative Experiment

// App.jsx (callback-demo/src/App.jsx)
import { useState, memo } from 'react';

// Regular child component — renders every time the parent renders
function RegularChild({ name }) {
  console.log('RegularChild rendered');
  return <h1>{name}</h1>;
}

// Child component wrapped in memo — skips if props haven't changed
const MemoChild = memo(({ name }) => {
  console.log('MemoizedChild rendered');
  return <div>Hello, {name}</div>;
});

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Shaolin Team');

  console.log('App component rendered');

  return (
    <>
      <button onClick={() => setCount(count + 1)}>Click Count {count}</button>
      <button onClick={() => setName("Emei Team")}>Change Name</button>
      <RegularChild name={name} />
      <MemoChild name={name} />
    </>
  );
}

5.3 Two Experiments, Two Conclusions

Experiment 1: Click "Click Count"

Experiment 2: Click "Change Name"

5.4 What does memo do under the hood?

memo is a Higher-Order Component (HOC). You pass it a function component, and it returns a new "memorized" component. Before each render, this new component does one thing:

Take current props → Compare with previous props one by one using shallow comparison (===) →
  ├─ All identical → Skip rendering, directly reuse the previous round's virtual DOM result
  └─ Any difference → Render honestly

Shallow comparison = ===:

"Shaolin Team" === "Shaolin Team"   // true → skip rendering
"Shaolin Team" === "Emei Team"   // false → render normally

⚠️ There's a pitfall here: Because the comparison is by reference, if the props you pass are objects, arrays, or functions, each parent render generates a brand new reference, === always returns false, and memo becomes useless. This is precisely why useCallback (to cache function references) and useMemo (to cache computed results) are needed next — this is the reason the project is named callback-demo.


6. Connecting the Two Threads

Controlled/Uncontrolled is about "where the data lives", React.memo is about "can the render be skipped". But ultimately, they both revolve around React's most core mechanism:

State drives the view. useState is React's pulse — when it beats, the entire component tree starts re-rendering layer by layer. Controlled components leverage this pulse to synchronize the DOM; React.memo attempts to block unnecessary pulse propagation, keeping unchanged subtrees silent.

Once you thoroughly understand this pulse and the blocking mechanism, the subsequent useCallback, useMemo, Context, and lifting state up — are all just building on this main thread.

Component Index: The Design of a Manifest File

Finally, a mention of an engineering detail. As components multiplied, I created a manifest file (barrel export) in components/index.js:

// As components grow, export all components from index.js
// Manifest file
import ControlledInput from './ControlledInput';
import UncontrolledInput from './UncontrolledInput';
import CommentBox from './CommentBox';
import RegisterForm from './RegisterForm';
import LoginForm from './LoginForm';

export {
  ControlledInput,
  UncontrolledInput,
  CommentBox,
  RegisterForm,
  LoginForm
};

And App.jsx only needs one line:

import { ControlledInput, UncontrolledInput, CommentBox, RegisterForm, LoginForm }
  from './components';

Consolidating all component imports into a single entry point means the consumer doesn't need to know which file each component lives in — this is a common practice in React projects and the final piece of componentization.