Topic 13: React Forms
📖 9 min read · 🎯 advanced · 🧭 Prerequisites: networking, generating-signed-apk
Why this matters
You've already built pages that look great — but here's the thing: most real apps need users to actually send something back. A login screen, a signup page, a checkout form, a search bar. The moment a user types into a field and clicks Submit, your app needs to capture that, validate it, and act on it. React gives you precise control over every keystroke, every selection, every submission — through controlled components, multi-input state, validation, and refs. Get this right, and you can handle almost any form in any app.
What You'll Learn
- Create basic React forms with
useStateand controlledonChangehandlers - Use a single state object to manage multiple form fields efficiently
- Validate form data before submission and surface inline error messages
- Access form element values directly using
useRefas an uncontrolled alternative
The Analogy
Think of a React controlled form like a live court stenographer. Every word spoken in the courtroom is immediately transcribed — the stenographer's notebook (component state) is always in perfect sync with what was just said (the input value). If you want to know what was said at any moment, you check the notebook, not the speaker's memory. An uncontrolled form using refs, by contrast, is like asking a witness to recall their testimony only when the judge requests it — the data sits untouched in the DOM until you reach in and pull it out at submission time.
Chapter 1: Basic Form Handling
In React, forms look similar to plain HTML forms, but React hands you finer control over data and user interactions. The key difference is that each input's value is driven by component state, and every change fires a handler that updates that state — keeping React as the single source of truth.
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;
Integrate SimpleForm into your root component to see it in action:
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;
Key points:
event.preventDefault()stops the browser's default page-reload behavior on form submit.value={name}makes the input a controlled input — React owns the displayed value.onChangefires on every keystroke, keeping state and UI in sync.
Chapter 2: Controlled Components
A controlled component is one where React state is the sole source of truth for the input's value. Rather than maintaining separate state variables for every field, the class graduates to a single formData object — one handleChange function dispatches updates to whichever field triggered the event by reading event.target.name.
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 spread ...formData preserves existing field values while the computed property [name]: value updates only the changed field. This pattern scales cleanly regardless of how many fields you add.
flowchart LR
User["User types"] --> onChange["onChange fires"]
onChange --> handler["handleChange reads\nevent.target.name + value"]
handler --> setState["setFormData spreads\nexisting state + new value"]
setState --> rerender["React re-renders\nwith updated value prop"]
rerender --> input["<input value={...} />"]
input --> User
Chapter 3: Handling Multiple Inputs
The same single-handler pattern extends naturally to every HTML form element type: text inputs, number inputs, and <select> dropdowns all emit event.target.name and event.target.value.
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;
Notable patterns:
<select>is controlled the same way as<input>— bindvalueon the element, notselectedon an<option>.- Setting
gender: 'male'as the initial state pre-selects that option on first render. type="number"inputs still return strings viaevent.target.value; parse withNumber()orparseInt()if you need arithmetic.
Chapter 4: Form Validation
Submitting bad data is worse than not submitting at all. The class implements a validate() function that populates an errors object. If any errors exist, submission is blocked and inline messages are rendered next to the offending fields.
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 applied:
- Email — required; must match the regex
/\S+@\S+\.\S+/(at least one non-whitespace character on each side of@and.). - Password — required; minimum length of 6 characters.
validate()returnstrueonly whentempErrorsis empty, gating the submit logic.{errors.email && <span>}short-circuits: the span only renders when an error string exists.
Chapter 5: Using Refs for Form Elements
Sometimes you don't want React managing every keystroke — you just need the value at submission time. useRef gives you a direct reference to a DOM node so you can read .current.value on demand. This is called an uncontrolled component pattern.
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;
When to prefer refs over controlled state:
- Integrating with third-party DOM libraries that manage their own input values.
- One-shot read (file inputs, focus management) where syncing state on every keystroke is unnecessary overhead.
- The trade-off: you lose the ability to programmatically reset, validate on-the-fly, or derive UI from input values without an extra read.
🧪 Try It Yourself
Task: Build a RegistrationForm component that collects username, email, password, and confirmPassword. Add validation that:
- All fields are required.
emailpasses the/\S+@\S+\.\S+/test.passwordis at least 6 characters.confirmPasswordmatchespassword.
On successful validation, log the form data to the console. On failure, display inline error messages beneath each offending field.
Starter snippet:
import React, { useState } from 'react';
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
});
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('Registered:', formData);
}
};
return (
<form onSubmit={handleSubmit}>
{/* TODO: render inputs for each field + error spans */}
<button type="submit">Register</button>
</form>
);
}
export default RegistrationForm;
Success criterion: Open the browser console. A successful submission logs Registered: { username: '...', email: '...', ... }. Submitting with a mismatched password should show "Passwords do not match" beneath the confirmPassword field — no console log.
🔍 Checkpoint Quiz
Q1. What makes a form input a "controlled component" in React?
A) It uses a ref to read the DOM value at submit time
B) Its value prop is bound to component state and updated via onChange
C) It is wrapped in a <form> element
D) It uses defaultValue instead of value
Q2. Given this snippet, what appears in the console when the user types "Alice" into the name field and submits?
const [formData, setFormData] = useState({ name: '', email: '' });
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({ [name]: value }); // note: no spread
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(formData);
};
A) { name: 'Alice', email: '' }
B) { name: 'Alice' }
C) { email: '' }
D) { name: '', email: '' }
Q3. In ValidatedForm, what does validate() return when both email and password are valid?
A) null
B) The formData object
C) true
D) false
Q4. You need to integrate a third-party rich-text editor that manages its own internal DOM state. Should you use a controlled component (state + onChange) or a useRef? Why?
🔍 Checkpoint Quiz — Answers
A1. B — A controlled component keeps value in sync with React state; every keystroke flows through onChange → setState → re-render.
A2. B — The handleChange uses { [name]: value } without spreading ...formData, so each change replaces the entire state object with only the updated field. After typing in the name field, email is gone from state.
A3. C — validate() returns Object.keys(tempErrors).length === 0, which is true when no errors were added to tempErrors.
A4. Use useRef. Third-party editors own their own internal state and often expose a getValue() API rather than firing native onChange events. Trying to control them via React state creates a two-source-of-truth conflict. A ref lets you reach in and read the value at submit time without fighting the library.
🪞 Recap
- Controlled inputs bind
valueto React state andonChangeto a state setter, making React the single source of truth for all form data. - A single
handleChangeusingevent.target.nameand computed property syntax[name]: valuehandles any number of fields with no duplication. <select>dropdowns follow the same controlled pattern as text inputs — bindvalueon the element, notselectedon an option.- The
validate()pattern populates anerrorsobject and returns a boolean, cleanly separating validation logic from the submit handler. useRefis the right tool when you need a one-shot DOM read at submit time and don't need React to track intermediate keystrokes.
📚 Further Reading
- React Docs — Forms — the official guide to controlled inputs and form state
- React Docs — useRef — when and how to reach into the DOM without state
- React Hook Form — PLACEHOLDER — a popular library that reduces boilerplate for large, complex forms
- ⬅️ Previous: Generating Signed APK
- ➡️ Next: Image Upload