跪拜 Guibai
← Back to the summary

Building a Login Form in React: One Handler, Two States, and Zero Stale Closures

Think Before Reading

  1. If there are 5 input fields, would you write a separate onChange handler for each one? Is there a way to share one?
  2. Where should form validation results be stored—in the same state as the form data, or in a separate state?
  3. Should the isValid variable be defined with useState? Why or why not?

Main Thread

Starting from a controlled component for a single field, we solve three practical problems in sequence: what to do when there are many fields (sharing one handler), where to add validation logic (separating into two states), and how the UI automatically links up (derived values + conditional rendering). Each problem naturally leads from the "not good enough" of the previous step to the "better way to write it" of the next, ultimately stringing together a complete data flow loop for a full form.


useRef vs useState: Two Ways to "Manage Data"

Key Points

There are only two choices for managing form data in React: put it in useRef (the value lives in the DOM, no re-renders triggered) or put it in useState (the value lives in state, re-renders on every change).

useRef returns a plain JS object shaped like { current: null }, and its reference stays the same throughout the component's lifecycle. Modifying ref.current is a plain assignment; React neither knows nor cares. Reading a DOM value via a ref is "silent"—it triggers no re-render.

useState returns [value, setValue]. Calling setValue queues a re-render, and React re-executes the component function. Passing state to <input value={state}> forms a closed loop—the value displayed in the input box is always exactly equal to the state.

Key Code

// Uncontrolled: value is in the DOM, "peeked at" when needed
import { useRef } from 'react';

function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleClick = () => {
    console.log(inputRef.current.value);
  };

  return (
    <>
      <input type="text" ref={inputRef} />
      <button onClick={handleClick}>Get Input Value</button>
    </>
  );
}
// Controlled: value is in state, re-renders on every keystroke
import { useState } from 'react';

function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input
      type="text"
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

Execution Process

Uncontrolled component: User types "hello" → DOM's value changes character by character → React doesn't perceive it, no setState, no re-render. Click the button → inputRef.current.value reads the DOM, gets "hello" → prints it. The entire process has zero re-renders.

Controlled component: User presses "h" → onChange fires → setValue("h") → state becomes "h" → React re-renders → <input value="h"> syncs the DOM with state. Press "e" → run it again. Type 5 characters, the component renders 5 times.

Execution path differences between the two modes:

Uncontrolled (one-way read):
  User input → DOM update → React doesn't perceive, no re-render
                        → Click button → ref.current.value reads DOM → prints

Controlled (closed loop):
  User input → onChange → setState → re-render → value syncs to DOM
                                           → User continues input → back to onChange

Left side, uncontrolled: Data is only read one-way between the DOM and React; the path is short but React's perception is weak. Right side, controlled: Data forms a closed loop, passing through React every time; the path is long but every step is controllable.

Why It's Designed This Way

A controlled component's "re-render on every keystroke" is not a performance flaw but a design choice—for a single input field, the rendering overhead is negligible, and what you gain is that state is always the latest value. You can perform validation, formatting, and UI linking at any time inside onChange. Uncontrolled components are a pragmatic "escape hatch": for simple scenarios where you only need the value once at submission, there's no need to let React manage every keystroke.

Easy to Confuse

To judge whether a component is controlled or uncontrolled, don't look at whether it uses useRef or useState; only look at whether the input's value attribute is driven by React state. A component can have both useState and <input ref={...}>; as long as the value isn't bound to state, it's still uncontrolled.

Usage Boundaries

Changes to a useRef value don't trigger re-renders, so <p>{ref.current?.value}</p> won't automatically update as you type. If you need to display the input value in real-time, you must go the controlled route.

Uncontrolled in Practice: CommentBox

function CommentBox() {
  const textareaRef = useRef(null);

  const handleSubmit = () => {
    const comment = textareaRef.current.value;
    if (!comment) return;         // early return: don't submit empty content
    console.log(comment);
  };

  return (
    <div>
      <textarea placeholder="Enter comment..." ref={textareaRef} />
      <button onClick={handleSubmit}>Submit Comment</button>
    </div>
  );
}

This is a variant of an uncontrolled component on a <textarea>, with logic identical to an input field. A pattern worth noting is if (!comment) return—using an early return for defense. No need to pop up an error message, no need to disable the button; one line of guard code is enough. This also reflects the typical judgment for uncontrolled components: if the interaction is simple enough, there's no need to pull in the full controlled mechanism.


Multi-Field Forms: One Handler to Rule All Fields

Key Points

When a form has multiple input fields, all fields share one handler, using e.target.name to decide which field to update. [e.target.name]: e.target.value uses ES6 computed property name syntax, letting a variable's value become an object's key. ...form uses the spread operator to preserve fields in the old object that weren't updated this time.

Real Problem

RegisterForm has two fields: username and password. If you write a separate handler for each, you have to manually hardcode the field name in every one. Adding a third field means writing yet another handler. The shared handler solution only requires adding a name attribute to the new <input>.

Key Code

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

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm({
      ...form,           // preserve unchanged fields
      [name]: value      // update the current field
    });
  };

  return (
    <>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
        type="text"
      />
      <input
        name="password"
        value={form.password}
        onChange={handleChange}
        type="text"
      />
    </>
  );
}

Execution Process

User types "z" in the username field:

e.target.name = "username", e.target.value = "z"
→ handleChange executes
→ setForm({ ...{username:"",password:""}, username:"z" })
→ form becomes { username:"z", password:"" }

User types "1" in the password field:

e.target.name = "password", e.target.value = "1"
→ setForm({ ...{username:"z",password:""}, password:"1" })
→ form becomes { username:"z", password:"1" }

Why It's Designed This Way

...form ensures that each update only changes one field. Remove it, and setForm({ [name]: value }) would leave form with only one field—the previously filled username would be lost when typing the password.

onChange={handleChange} passes a function reference, not a call result. React calls it only when the event fires, automatically passing in the synthetic event object e. e is not passed by the caller; React internally captures the native event → wraps it into a SyntheticEvent → automatically calls handleChange(e). If written as onChange={handleChange()}, it would execute immediately during rendering, and e would be undefined—this is a common mistake in JSX.

Easy to Confuse

Although RegisterForm already has multiple fields, it only manages data and not validation. Validation, error messages, and button linking are completely absent from it—these are the problems the next step, LoginForm, will solve.


RegisterForm vs LoginForm: From "Usable" to "Pleasant to Use"

Before diving into LoginForm, let's lay out the differences between the two components:

RegisterForm LoginForm
Form Data form state form state
Error State None errors state
Validation Logic None validate function
Submit Button No limit, clickable anytime disabled={!isValid}
Error Message UI None Error messages
How errors update Functional prev =>

RegisterForm solved the problem of "how to collect multi-field data." LoginForm adds four layers on top of it: validation, error messages, button linking, and submission defense.


Separating Data and Validation: The Dual State Design

Key Points

When real-time validation is needed, you should use two states: form for values, errors for validation results. With separated responsibilities, data updates and validation are independent and don't pollute each other.

The validation function's msg starts as "" (empty string). When a rule isn't hit, msg stays ""; it's only assigned an error message when hit. This means the single value msg = "" carries two meanings: "no error" and "clear old error."

Key Code

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);
  };

  // ...
}

Execution Process

Each execution of handleChange does two things: updates data + triggers validation. validate receives the parameter value instead of reading form[name]—because setForm only queues an update, the form in the closure is still the old value; reading it directly would get the previous version of the data.

User input
  ↓
handleChange executes
  ├── setForm (writes data)
  └── validate(name, value)
        ↓
      Rule hit?
        ├── Yes → msg = "Username is empty"
        └── No → msg = ""
        ↓
      setErrors(prev => ({...prev, [name]: msg}))
  ↓
React batches the two setStates, one re-render

React's batching makes setForm and setErrors complete in a single render—the user sees the data update and error change take effect simultaneously, without an intermediate frame where "the input changed but the error message didn't."

Why It's Designed This Way

The initial value of msg is "" rather than null or undefined because conditional rendering uses && short-circuiting: in {errors.xxx && <span>}, "" is falsy, so React doesn't render it. So when validation passes, writing "" into errors makes the error message disappear automatically—no extra "clear error" logic needed. One data path covers both directions: "reporting errors" and "correcting errors."

Why validate Doesn't Read form but Gets the Value via Parameter

setForm only queues an update; at that moment, form in the closure is still the old value. If validate internally read form.username, it would get the content from before the user's input. Passing the parameter sidesteps the closure problem, ensuring the validation checks the latest input.


Functional Updates: prev Can Save Your Data

Key Points

setState has two argument forms: passing an object (setXxx({...old, key: new})) and passing a function (setXxx(prev => ({...prev, key: new}))). The difference lies in "where the old value comes from"—passing an object reads from a closure snapshot; passing a function gets it from React's internal update queue, and prev is guaranteed to be the latest.

Real Problem

The validate function is written inside the component body, and the errors it captures in its closure is the value from the current render. If you use the object-passing form setErrors({...errors, [name]: msg}), during multiple rapid validations, the later execution might get a stale closure errors, causing the previous update to be overwritten.

Key Code

// Passing an object — depends on errors in the closure
setErrors({
  ...errors,        // ← This errors might be a stale snapshot
  [name]: msg
});

// Passing a function — React guarantees prev is the latest
setErrors(prev => ({
  ...prev,          // ← prev is always the latest
  [name]: msg
}));

Execution Process

Assume errors is currently {}, and the user rapidly triggers two validations for username and password. Using the object-passing method, both read errors = {} from their respective closures, so the later execution overwrites the earlier one. Using the function-passing method, the second execution's prev already contains the result of the first update; neither update is lost.

Why It's Designed This Way

React's update queue executes sequentially. When passing a function, React passes the state returned by the previous one to the next updater's prev, forming a chain of updates. Passing an object bypasses the chain—it takes the value directly from the closure.

In RegisterForm, setForm({...form, [name]: value}) using an object is fine: onChange triggers one render per keystroke, and there's no scenario of multiple setForm calls within the same cycle. Using the functional form for setErrors is defensive—validate might be called from multiple places, and using prev guarantees no data is lost at any time.

Usage Boundaries

If you're unsure whether the closure might be stale, use the functional form. The functional form is always correct; it just costs a few extra characters.


Derived Values, Conditional Rendering, and Form Submission

Key Points

A derived value is a value computed directly from existing state; it doesn't need its own useState. isValid is calculated from a combination of form and errors, automatically recomputed on every render, naturally linking up with the UI.

Conditional rendering leverages JS's && short-circuit evaluation: a && b returns a when a is falsy, and b is not executed. React doesn't render undefined, null, or "", so the <span> doesn't appear in the DOM.

<span> is an HTML inline element with no default styling, no line break, used only to wrap text fragments for attaching CSS class names.

Key Code

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-card" 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>
);

Execution Process

Below is a complete walkthrough tracing the user from opening the page to submission:

Initialization:

form:   { username: "", password: "" }
errors: {}
isValid: "" && "" && !undefined && !undefined = false
Result: Both input fields empty, no error messages, button greyed out

User inputs username = "zh" (length 2):

handleChange → setForm → form = { username: "zh", password: "" }
             → validate → errors = { username: "Username must be at least 3 characters" }
isValid: "zh" && "" && !"Username must be at least 3 characters" && !undefined
       = true && false && false && true = false
Result: Username field shows "zh", red prompt "Username must be at least 3 characters", button greyed out

User adds a "g" → "zhg" (length 3):

handleChange → setForm → form = { username: "zhg", password: "" }
             → validate → msg="" → errors = { username: "" }
isValid: "zhg" && "" && !"" && !undefined
       = true && false && true && true = false
Result: Username field shows "zhg", error message disappears, button still greyed out (password still empty)

User inputs password = "123456" (length 6):

handleChange → setForm → form = { username: "zhg", password: "123456" }
             → validate → msg="" → errors = { username: "", password: "" }
isValid: "zhg" && "123456" && !"" && !""
       = true && true && true && true = true
Result: Both input fields have values, no error messages, button lights up

User clicks "Login":

onSubmit → e.preventDefault() → isValid is true, skips return → console.log(form)

Here is the state-driven UI linkage:

State change (form or errors)
  ↓
Component re-renders
  ├── isValid recalculated
  │     ├── true  → button lights up
  │     └── false → button greyed out
  │
  └── errors.xxx check
        ├── truthy string → <span> renders, error message appears
        └── falsy / ""    → <span> doesn't render, error message disappears

Why It's Designed This Way

Not giving isValid its own state is a best practice. If setIsValid were scattered everywhere, sooner or later a scenario would be forgotten. A derived value has a single source—calculated at render time, it can't be missed or wrong.

Using && for conditional rendering is React's most concise convention. However, one caveat: if the value of errors.xxx is 0 or NaN, && would render them onto the page. In the current scenario, errors values are only "" and error message strings; "" doesn't render, strings render normally—perfectly safe.

!errors.username needs a separate explanation: errors.username is a string (like "Username is empty") or an empty string "". The ! negation turns a truthy string into false and a falsy empty string into true. So !errors.username being true means "this field has no error."

About the .error Class Name

className="error" has no corresponding CSS definition in the current project—none of the project's CSS files have an .error rule. At runtime, error messages display with the browser's default style and won't turn red. error is a semantic convention: the code level has already reserved a style hook, and CSS can be added at any time. The naming itself is documentation—seeing className="error" tells you this is an error message.

The Layout Design of form-item

<div className="form-item">
  <label>Username</label>
  <input name="username" value={form.username} onChange={handleChange} />
  {errors.username && <span className="error">{errors.username}</span>}
</div>

Each form-item wraps the three elements—label, input, error—into an independent, vertically arranged unit. The benefit of this structure: fields have spacing between them; the three internal elements are arranged vertically; CSS only needs to target .form-item to uniformly control the layout of all fields. Multiple form-items in parallel form the skeleton of the form.


Stringing the Code Together

The overall structure of each component:

App
 ├── ControlledInput      ← Controlled single field
 ├── UncontrolledInput    ← Uncontrolled single field
 ├── CommentBox           ← Uncontrolled textarea + early return
 ├── RegisterForm         ← Multi-field shared handler
 └── LoginForm            ← Dual state + real-time validation + derived values

LoginForm's complete interaction flow:

User presses a key
  ↓
onChange → handleChange
  ├── setForm (writes data)
  └── validate (writes errors)
  ↓
React batches → one re-render
  ├── input value syncs to latest form
  ├── error span shows/hides
  └── isValid recalculated → button lights up/greys out
  ↓
{ Continue typing? } ── Yes ──→ Back to start
  ↓ No
isValid = true?
  ↓ Yes
User clicks Login or presses Enter
  ↓
onSubmit → e.preventDefault()
  ↓
if (!isValid) return (defense)
  ↓
Submit data

Extension: Component Organization

components/index.jsx gathers all components in one file for unified export:

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
};

The benefit of this barrel export is that importing can be done in one line: import { ControlledInput, LoginForm } from './components'. The more components you have, the more obvious the benefit.

Final Review

From ControlledInput to LoginForm, each step solves a practical problem:

These patterns can be used independently or combined. Understanding their data flow is more important than memorizing the code itself.