React Forms
Forms in React work a bit differently from plain HTML forms. In HTML, form elements like <input> and <textarea> keep their own internal state. You usually read the values when the form is submitted.
In React, the recommended pattern is to keep the form values in component state and update them as the user types. This is called a controlled component. React becomes the single source of truth for what is in the form.
Why would you want this? Because it gives you immediate access to the input values. You can validate as the user types, enable or disable the submit button based on what they entered, or transform the input (like converting to uppercase) in real time.
Controlled Inputs
The key idea is simple: the input’s value comes from state, and every keystroke calls a handler that updates state.
Here is a single text input:
import { useState } from 'react';
function NameForm() {
const [name, setName] = useState('');
return (
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
/>
<p>You typed: {name}</p>
</div>
);
}
export default NameForm;
Two attributes on the <input> wire it up to React:
value={name}makes the input display whatever is in state.onChangefires on every keystroke and updates state withe.target.value.
As soon as you type a letter, onChange fires, setName updates state, React re-renders, and the new value appears in the input. This loop happens so fast it feels instant.
Textarea
<textarea> works the same way as <input>. Use a value attribute and an onChange handler:
import { useState } from 'react';
function MessageForm() {
const [message, setMessage] = useState('');
return (
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={4}
placeholder="Type your message..."
/>
);
}
In plain HTML, <textarea> content goes between the opening and closing tags. In React, it is controlled through the value attribute, just like <input>.
Checkbox
Checkboxes use checked (a boolean) instead of value:
import { useState } from 'react';
function TermsForm() {
const [agreed, setAgreed] = useState(false);
return (
<div>
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
I agree to the terms
</label>
<p>Agreed: {agreed ? 'Yes' : 'No'}</p>
</div>
);
}
The handler reads e.target.checked (a boolean) rather than e.target.value.
Select Dropdown
Use value on the <select> element to control which option is selected:
import { useState } from 'react';
function FruitPicker() {
const [fruit, setFruit] = useState('apple');
return (
<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="mango">Mango</option>
<option value="strawberry">Strawberry</option>
</select>
);
}
The selected attribute you would use in plain HTML is not needed here. React handles which option is shown based on the value on <select>.
Multiple Fields with One State Object
When a form has several fields, you can manage them all in a single state object. Use the name attribute on each input to know which field to update:
import { useState } from 'react';
function ProfileForm() {
const [form, setForm] = useState({
firstName: '',
lastName: '',
email: '',
});
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
};
return (
<form>
<input
name="firstName"
value={form.firstName}
onChange={handleChange}
placeholder="First name"
/>
<input
name="lastName"
value={form.lastName}
onChange={handleChange}
placeholder="Last name"
/>
<input
name="email"
type="email"
value={form.email}
onChange={handleChange}
placeholder="Email"
/>
</form>
);
}
The spread operator (...prev) copies all the existing fields so you only overwrite the one that changed. Without it, setting firstName would erase lastName and email.
Form Submission
Add an onSubmit handler to the <form> element. Always call e.preventDefault() to stop the browser from doing a full page reload:
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitted:', form);
// send form data to an API here
};
return <form onSubmit={handleSubmit}>...</form>;
Without e.preventDefault(), the browser will refresh the page when the form is submitted, losing all your React state.
Complete Contact Form Example
Here is a contact form with multiple fields, validation, and submission handling:
import { useState } from 'react';
function ContactForm() {
const [form, setForm] = useState({
name: '',
email: '',
message: '',
newsletter: false,
});
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
const handleChange = (e) => {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
if (!form.name || !form.email || !form.message) {
setError('Please fill in all required fields.');
return;
}
setError('');
setSubmitted(true);
console.log('Form submitted:', form);
};
if (submitted) {
return <p>Thanks for reaching out, {form.name}!</p>;
}
return (
<form onSubmit={handleSubmit}>
<h2>Contact Us</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<div>
<label htmlFor="name">Name *</label>
<input
id="name"
name="name"
type="text"
value={form.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email">Email *</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="message">Message *</label>
<textarea
id="message"
name="message"
value={form.message}
onChange={handleChange}
rows={5}
/>
</div>
<div>
<label>
<input
name="newsletter"
type="checkbox"
checked={form.newsletter}
onChange={handleChange}
/>
Subscribe to newsletter
</label>
</div>
<button type="submit">Send message</button>
</form>
);
}
export default ContactForm;
Notice how handleChange checks type === 'checkbox' and reads checked instead of value. This lets one handler work for both regular inputs and checkboxes.
Common Mistakes
Not calling e.preventDefault()
Forgetting this causes the browser to submit the form the old-fashioned way (full page reload). Your React state disappears and any API call you were about to make never happens.
Mutating state directly
This does not trigger a re-render:
// Wrong
form.name = 'Alice';
setForm(form);
// Correct
setForm(prev => ({ ...prev, name: 'Alice' }));
Always create a new object when updating object state. Use the spread operator to copy existing values.
Using array index as key for dynamic fields
If you have a list of dynamic inputs (like “add another email address”), do not use the array index as the key. If items can be removed or reordered, React will mix up the inputs. Use a stable unique ID instead.
Form Libraries
For simple forms, useState is all you need. For complex forms with many fields, nested validation rules, or async validation, consider React Hook Form
.
React Hook Form uses uncontrolled inputs under the hood, which means it does not re-render the whole form on every keystroke. This makes it significantly faster for large forms. It also has built-in support for schema validation with libraries like Zod and Yup.
npm install react-hook-form
For a basic text input with validation:
import { useForm } from 'react-hook-form';
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: 'Email is required',
pattern: { value: /\S+@\S+\.\S+/, message: 'Invalid email' },
})}
placeholder="Email"
/>
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Sign up</button>
</form>
);
}
Legacy: Class Component Forms
In older React code, forms were built using class components and this.state. You may see this pattern when maintaining an existing codebase:
import { Component } from 'react';
class NameForm extends Component {
constructor(props) {
super(props);
this.state = { name: '' };
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(event) {
this.setState({ name: event.target.value });
}
handleSubmit(event) {
event.preventDefault();
alert('Submitted: ' + this.state.name);
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<input
type="text"
value={this.state.name}
onChange={this.handleChange}
/>
<button type="submit">Submit</button>
</form>
);
}
}
The pattern is the same: controlled inputs tied to state. The difference is syntax. For new code, use the functional component approach with useState.
FAQ
Should I use a form library?
For simple forms with one or two fields, useState is easier and adds no dependencies. Once you have more than about five fields, nested validation, async checks, or a need to reset the form to a default state, React Hook Form is worth adding. It handles edge cases that are tedious to handle manually (like showing validation errors only after the user has touched a field).
What is a controlled component?
A controlled component is an input whose value is driven by React state. The input always shows what state contains, and every change updates state via an onChange handler. This is the opposite of an uncontrolled component, where the DOM manages the value and you read it with a ref when you need it.
How do I handle file uploads?
File inputs cannot be controlled the same way as text inputs because browsers do not allow setting the file input’s value programmatically. Use an uncontrolled approach with a ref, or just read the file from the event:
function FileUpload() {
const handleChange = (e) => {
const file = e.target.files[0];
console.log(file.name, file.size);
};
return <input type="file" onChange={handleChange} />;
}
To upload the file, create a FormData object and send it with fetch or a library like Axios.
See also