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

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

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

Multi-field forms are where React state management stops being obvious: a shared handler, separate validation state, and derived booleans prevent the sprawl of per-field onChange functions and out-of-sync isValid flags that plague quick-and-dirty form code. The pattern scales to any number of fields without adding complexity.

Summary

Starting from a single controlled input, the form grows field by field: first a shared onChange handler that routes updates by `e.target.name`, then a separate `errors` state that holds validation results as strings. The validation function receives the new value as a parameter to avoid reading a stale closure, and it writes error messages with a functional `setErrors(prev => ...)` so rapid keystrokes never overwrite each other. An `isValid` boolean is computed on every render from `form` and `errors`—no dedicated useState needed—and drives both the submit button's `disabled` prop and a guard clause inside `onSubmit`.

The design hinges on a few deliberate choices. An empty string `""` doubles as "no error" and "clear the error," which makes conditional rendering with `&&` safe and eliminates explicit error-clearing logic. The `validate` function never reads `form` directly because `setForm` only queues an update; passing the value as an argument keeps the check in sync with the latest keystroke. React's automatic batching merges the data and error state updates into a single render, so the input value and any error message always change in the same frame.

Takeaways
All input fields in a form can share a single onChange handler that uses `e.target.name` as the state key, avoiding per-field handler duplication.
Form values and validation errors should live in two separate useState hooks so data updates and validation logic don't pollute each other.
Pass the new value directly to the validate function as an argument rather than reading from state, because setState only queues an update and the closure still holds the old value.
Use the functional updater `setErrors(prev => ...)` to prevent rapid successive validations from overwriting each other when the closure might be stale.
An `isValid` boolean should be a derived value computed during render from `form` and `errors`, not stored in its own useState that can fall out of sync.
Empty string `""` as the default error message is both falsy for conditional rendering and semantically means "no error," so a single data path handles showing and hiding errors.
React batches setForm and setErrors calls from the same event handler into one render, so the UI never shows an intermediate state where the input updated but the error didn't.
An early return inside onSubmit (`if (!isValid) return`) adds a server-side-agnostic guard that works even if the button's disabled attribute is bypassed.
Conclusions

The tutorial's progression mirrors how form complexity actually grows in a codebase: start with one field, hit pain, refactor—rather than presenting a finished pattern with no context for why each piece exists.

Using an empty string as both 'no error' and 'clear error' is a subtle design move that eliminates an entire class of state-management bugs; many developers reach for null or undefined and then need extra logic to distinguish 'unchecked' from 'valid.'

The distinction between passing an object vs. a function to setState is often treated as a React trivia question, but the validate-and-overwrite race condition shown here is a concrete, reproducible bug that the functional form prevents.

The barrel export pattern shown at the end is a small organizational detail that pays off as component count grows, yet it's frequently omitted from form tutorials that focus only on hooks.

Concepts & terms
Controlled component
A form element whose value is driven by React state via the `value` prop, with an `onChange` handler that updates that state on every keystroke, creating a closed loop where React is the single source of truth.
Uncontrolled component
A form element whose value lives in the DOM and is accessed via a ref only when needed, without triggering re-renders on each keystroke. Useful for simple scenarios like a comment box where real-time React awareness isn't required.
Derived value
A value computed directly from existing state during render, rather than stored in its own useState. Since it's recalculated on every render, it can never fall out of sync with the state it depends on.
Functional updater
Passing a function `prev => newState` to setState instead of an object. React guarantees `prev` is the latest state from its internal queue, preventing stale-closure overwrites when multiple updates happen in rapid succession.
Barrel export
An index.js file that re-exports multiple modules from a directory, allowing consumers to import several components from a single path (`import { A, B } from './components'`) instead of multiple individual import statements.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗