Topic 13: React Forms
📖 9 min read · 🎯 Advanced · 🧭 Prerequisites: actions-and-reducers, networking
Why this matters
Picture this: you're building a signup page, a login form, a search bar — basically anything real that users actually type into. Without knowing how React handles forms, you're guessing. And guessing with forms leads to bugs you can't explain: values that don't update, validations that fire at the wrong time, inputs that feel broken. I've seen students spend hours on this. Today we fix that. We're going to cover controlled components, multi-input state, validation, and refs — the four things React needs from you to make forms actually work.
What You'll Learn
- Build a basic controlled form with
useStateandonChangehandlers - Manage multiple inputs with a single shared state object and a generic
handleChange - Validate form data before submission and render inline error messages
- Use
useRefto read uncontrolled input values directly from the DOM
The Analogy
Think of a paper form on a government desk. An uncontrolled form is like handing the citizen a pen and letting them write directly on the paper — you only read it when they hand it back. A controlled form is like a clerk sitting beside them, typing every letter into a live database the moment they speak it. React's controlled-component pattern is the clerk: every keystroke flows into state, state flows back into the input, and you always know exactly what's in every field without waiting for the form to be submitted.
Chapter 1: Basic Form Handling
In React, forms look like regular HTML forms, but React takes ownership of the data. Instead of the browser tracking what's in each field, your component's state does — which means you can read, transform, or validate values at any moment.
src/components/SimpleForm.js:
import React, { useState } from 'react';
function SimpleForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
alert(`Name: ${name}, Email: ${email}`);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<label>Email:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<button type="submit">Submit</button>
</form>
);
}
export default SimpleForm;
Key points:
event.preventDefault()stops the browser from doing a full-page POST, keeping the app in SPA territory.- Each input has both a
valueprop (read from state) and anonChangehandler (writes to state). Remove either one and the field breaks.
Integrating into App — src/App.js:
import React from 'react';
import './App.css';
import SimpleForm from './components/SimpleForm';
function App() {
return (
<div className="App">
<h1>React Forms</h1>
<SimpleForm />
</div>
);
}
export default App;
Chapter 2: Controlled Components
A controlled component is one where React state is the single source of truth for every input value. The component renders what state says; the user types; onChange fires; state updates; the component re-renders with the new value. The DOM never leads — it only follows.
the trainer drew the data-flow loop on the board:
flowchart LR
User -->|types| onChange
onChange -->|calls setState| State
State -->|value prop| Input
Input -->|renders| User
When a form grows beyond two fields, keeping one useState call per field becomes noise. Consolidate them into a single object and write one generic handleChange:
src/components/ControlledForm.js:
import React, { useState } from 'react';
function ControlledForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({
...formData,
[name]: value
});
};
const handleSubmit = (event) => {
event.preventDefault();
console.log('Form Data:', formData);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name:</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label>Email:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label>Message:</label>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
></textarea>
</div>
<button type="submit">Submit</button>
</form>
);
}
export default ControlledForm;
The computed property key [name]: value is the trick — it reads the name attribute from the DOM element and uses it as the state key, so a single handler covers all three fields (and can cover thirty).
Chapter 3: Handling Multiple Inputs
The same pattern scales to any input type: text, number, select, checkbox, radio. The name attribute on each element must match the key in your state object — that's the contract.
src/components/MultipleInputsForm.js:
import React, { useState } from 'react';
function MultipleInputsForm() {
const [formData, setFormData] = useState({
username: '',
age: '',
gender: 'male'
});
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({
...formData,
[name]: value
});
};
const handleSubmit = (event) => {
event.preventDefault();
console.log('Form Data:', formData);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Username:</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
/>
</div>
<div>
<label>Age:</label>
<input
type="number"
name="age"
value={formData.age}
onChange={handleChange}
/>
</div>
<div>
<label>Gender:</label>
<select
name="gender"
value={formData.gender}
onChange={handleChange}
>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<button type="submit">Submit</button>
</form>
);
}
export default MultipleInputsForm;
Notice gender starts as 'male' — the <select> reads its initial value from state, not from a selected attribute on an <option>. This is a common gotcha when converting HTML forms to React.
Chapter 4: Form Validation
Collecting data without validating it is like accepting unsigned contracts. Before you trust formData with a server, check it client-side: give the user immediate feedback, and only proceed when the data is clean.
The pattern:
- A separate
errorsstate object, keyed the same way asformData. - A
validate()function that populatestempErrorsand callssetErrors. validate()returnstrueonly when the errors object is empty.handleSubmitcallsvalidate()and bails out if it returnsfalse.
src/components/ValidatedForm.js:
import React, { useState } from 'react';
function ValidatedForm() {
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({
...formData,
[name]: value
});
};
const validate = () => {
let tempErrors = {};
if (!formData.email) {
tempErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
tempErrors.email = 'Email is invalid';
}
if (!formData.password) {
tempErrors.password = 'Password is required';
} else if (formData.password.length < 6) {
tempErrors.password = 'Password must be at least 6 characters';
}
setErrors(tempErrors);
return Object.keys(tempErrors).length === 0;
};
const handleSubmit = (event) => {
event.preventDefault();
if (validate()) {
console.log('Form Data:', formData);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label>Password:</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
/>
{errors.password && <span className="error">{errors.password}</span>}
</div>
<button type="submit">Submit</button>
</form>
);
}
export default ValidatedForm;
Validation rules used here:
- Email required:
!formData.email - Email format: regex
/\S+@\S+\.\S+/— not RFC-5322 complete, but catches obvious typos - Password required:
!formData.password - Password minimum length:
formData.password.length < 6
Error messages render inline with {errors.email && <span className="error">…</span>} — the short-circuit keeps the span out of the DOM entirely when there's no error.
Chapter 5: Using Refs for Form Elements
Sometimes you don't want React to own the field value at all — you just want to read the DOM node once at submit time. That's what useRef is for. The ref object's .current property points directly at the underlying DOM element, so you can call .value, .focus(), .select(), or any other native method.
src/components/RefForm.js:
import React, { useRef } from 'react';
function RefForm() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
const name = nameRef.current.value;
const email = emailRef.current.value;
alert(`Name: ${name}, Email: ${email}`);
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name:</label>
<input type="text" ref={nameRef} />
</div>
<div>
<label>Email:</label>
<input type="email" ref={emailRef} />
</div>
<button type="submit">Submit</button>
</form>
);
}
export default RefForm;
Notice there is no value prop and no onChange on these inputs — they are uncontrolled. React is not tracking what's in them; the browser is. The tradeoff:
| Controlled | Uncontrolled (ref) | |
|---|---|---|
| State in | React useState | DOM node |
| Instant access | Yes (from state) | No (must read .current.value) |
| Real-time validation | Easy | Hard |
| Reset on submit | setFormData({…}) | ref.current.value = '' |
| When to use | Almost always | Third-party DOM libs, file inputs, focus management |
🧪 Try It Yourself
Task: Build a registration form with three fields — username (text), email (email), and password (password). Validate all three on submit:
usernamemust not be emptyemailmust match/\S+@\S+\.\S+/passwordmust be at least 8 characters
Display inline error messages next to each field. On successful validation, log the formData object to the console.
Success criterion: Submitting an empty form shows three error messages. Submitting a valid form logs { username: '...', email: '...', password: '...' } to the browser console with no errors shown.
Starter snippet:
import React, { useState } from 'react';
function RegistrationForm() {
const [formData, setFormData] = useState({ username: '', email: '', password: '' });
const [errors, setErrors] = useState({});
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({ ...formData, [name]: value });
};
const validate = () => {
let tempErrors = {};
// TODO: add your validation rules here
setErrors(tempErrors);
return Object.keys(tempErrors).length === 0;
};
const handleSubmit = (event) => {
event.preventDefault();
if (validate()) {
console.log('Form Data:', formData);
}
};
return (
<form onSubmit={handleSubmit}>
{/* TODO: render inputs for username, email, password with error spans */}
<button type="submit">Register</button>
</form>
);
}
export default RegistrationForm;
🔍 Checkpoint Quiz
Q1. What makes a React form input a "controlled component"?
A) It uses a ref to read the DOM value
B) Its value prop is bound to state and onChange updates that state
C) It has a defaultValue attribute set
D) It calls event.preventDefault() on submit
Q2. Given the following handler, what gets logged when a user types "Alice" into <input name="username" /> and submits?
const [formData, setFormData] = useState({ username: '', role: 'viewer' });
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({ ...formData, [name]: value });
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(formData);
};
A) { username: 'Alice' }
B) { username: 'Alice', role: 'viewer' }
C) { name: 'username', value: 'Alice' }
D) Nothing — formData is stale
Q3. In the ValidatedForm example, the validate function returns Object.keys(tempErrors).length === 0. Why return this instead of just true?
A) It's a React requirement for form validators
B) It returns true only when no errors were added, letting handleSubmit skip submission when errors exist
C) It clears the errors state automatically
D) It prevents the browser from navigating away
Q4. You need to programmatically focus a text input when a modal opens. Should you use a controlled component or a useRef? Write a one-line explanation.
A1. B — A controlled component keeps its value in React state; the value prop pins what the DOM shows and onChange keeps state in sync.
A2. B — The spread ...formData preserves the existing role: 'viewer' key while [name]: value overwrites only username.
A3. B — Object.keys(tempErrors).length === 0 evaluates to true when the errors object is empty (no failing rules) and false when at least one rule failed, giving handleSubmit a clean boolean gate.
A4. Use useRef — you need to call .current.focus() imperatively on the DOM node, which state alone cannot trigger.
🪞 Recap
- React controlled components bind
valueto state andonChangeto a setter, making state the single source of truth for every input. - A single
handleChangefunction using[event.target.name]: event.target.valuecan manage an entire form object regardless of how many fields it has. - Form validation lives in a dedicated
validate()function that populates anerrorsstate object and returns a boolean;handleSubmitonly proceeds when it returnstrue. useRefgives you direct DOM access for uncontrolled inputs — useful for focus management and reading values once at submit time, but not recommended when you need real-time validation.- Always call
event.preventDefault()inhandleSubmitto stop the browser's native form submission from reloading the page.
📚 Further Reading
- React Docs — Forms — the official explanation of controlled vs uncontrolled components
- React Docs — useRef — full API reference for
useRefand uncontrolled patterns - MDN — HTML Forms Guide — deep dive into native form elements React wraps
- react-hook-form — PLACEHOLDER — performant, minimal-re-render form library worth knowing once you've mastered the basics here
- ⬅️ Previous: Networking
- ➡️ Next: Image Upload