Handling forms is one of the most common tasks in web development and, at the same time, where React differs the most from traditional HTML.
In classic HTML, form elements (like <input>, <textarea>, or <select>) maintain their own internal state. When you type in a text box, the browser saves that information in the DOM. You don’t have to do anything.
In React, however, our philosophy is that the component’s State should be the single source of truth.
We don’t want the DOM to store data behind React’s back. We want React to know at all times what is written, so it can validate, send, or transform it.
To achieve this, we use the pattern of Controlled Components.
What is a controlled component?
A controlled component is a form input whose value is controlled by React through state (useState).
The cycle works like this:
State ➡ Input: The input’s value attribute is set to the React state value.
User ➡ Event: The user presses a key, triggering onChange.
Event ➡ State: The handler function updates the state with the new value.
Re-render: React re-renders the input with the new state.
It seems like a long cycle for something as simple as typing a letter, but it’s what gives us absolute control over the data.
Implementing a basic Input
Let’s see it better by looking at an example of how to transform a normal input into a controlled one.
import { useState } from 'react';
export default function FormularioNombre() {
// 1. We create the state to save the value
const [nombre, setNombre] = useState('');
const handleChange = (e) => {
// 3. We update the state with what the user types
setNombre(e.target.value);
};
return (
<form>
<label>Tu nombre:</label>
{/* 2. We link value to state and onChange to the handler */}
<input
type="text"
value={nombre}
onChange={handleChange}
/>
<p>Estás escribiendo: {nombre}</p>
</form>
);
}
If you removed the line onChange={handleChange}, you would see that you couldn’t type anything.
Since value would be tied to an empty state and nothing updates that state, React would force the input to always stay empty, ignoring your keystrokes.
Why go to so much trouble?
It’s true that writing more code for a simple input seems tedious. But it has many advantages, especially regarding immediate reaction to changes.
- Immediate validation: We can show errors while the user is typing (e.g., “Password must be 8 characters”).
- Input masking: We can prevent certain characters. For example, force uppercase or prevent letters in a numeric field.
- Conditional buttons: We can disable the “Submit” button if the state is empty (
disabled={nombre.length === 0}).
For example, let’s see how to use it to make a field that forces us to use uppercase.
const handleChange = (e) => {
// We transform the data BEFORE saving it
const valorMayusculas = e.target.value.toUpperCase();
setNombre(valorMayusculas);
};
Textarea and Select
In HTML, some inputs have their own ways of doing things. <textarea> defines its value as child text, and <select> uses the selected attribute on its options.
React standardizes this so all inputs work the same, through the value attribute.
In React, <textarea> contains the text in the value attribute.
<textarea
value={mensaje}
onChange={e => setMensaje(e.target.value)}
/>
Instead of putting selected on the <option>, we put value on the parent <select>.
const [fruta, setFruta] = useState('coco');
<select value={fruta} onChange={e => setFruta(e.target.value)}>
<option value="lima">Lima</option>
<option value="coco">Coco</option>
<option value="mango">Mango</option>
</select>
Checkboxes and Radio Buttons
Checkboxes and Radio Buttons are the “odd ones out”.
- They don’t use
value, they usechecked. - In the event, we read
e.target.checked, note.target.value.
It’s a minor detail, but it often causes bugs if you try to apply the same generic logic to everything without looking at the type.
Managing multiple inputs
If we have a form with First Name, Last Name, Email, and Age… do we have to create 4 different useState variables and 4 different handleChange functions? It doesn’t seem very practical.
A common way to handle complex forms is to use a single state object and a generic update function that uses the input’s name attribute to know what to update.
export default function FormularioRegistro() {
// A single object-type state
const [datos, setDatos] = useState({
nombre: '',
email: '',
password: '',
aceptarTerminos: false
});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
// Special treatment for checkboxes
const valorFinal = type === 'checkbox' ? checked : value;
setDatos((prevDatos) => ({
...prevDatos, // We copy what was there
[name]: valorFinal // Computed Property Name: we update only the dynamic key
}));
};
return (
<form>
<input
name="nombre"
value={datos.nombre}
onChange={handleChange}
placeholder="Nombre"
/>
<input
name="email"
value={datos.email}
onChange={handleChange}
placeholder="Email"
/>
<label>
Acepto términos
<input
type="checkbox"
name="aceptarTerminos"
checked={datos.aceptarTerminos} // Note: Checkbox uses 'checked', not 'value'
onChange={handleChange}
/>
</label>
</form>
);
}
Notice the syntax [name]: valorFinal. It’s an ES6 feature called Computed Property Names that allows us to use a variable as an object key.
