Forms and Controlled Components
Course: React JS & Tailwind CSS - Full Course
Introduction
When you build web apps, forms are one of the main ways users interact with your application. Whether it’s logging in, signing up, leaving a comment, or updating settings, forms allow users to send information back to your app.
In React, handling forms works a little differently than in plain HTML, because React uses a concept called controlled components. Let’s break this down step by step.
Controlled Components
In normal HTML, form inputs (like <input>
, <textarea>
, <select>
) keep track of their own state. But in React, we often want React to be the source of truth for what’s inside those inputs.
A controlled component means that the value of the input is controlled by React state. This allows us to:
- Always know the current value of an input.
- Validate or modify input values before saving them.
- Reset or clear form fields easily.
- Respond immediately when a user types, clicks, or changes something.
Two-Way Data Binding
Two-way data binding simply means that the input shows whatever is in React state. When the user types or changes the input, it updates React state. Let's take a look at an example to better understand this.
React Forms with Different Input Types
We'll build a registration form with different input types to get you familiar with the process. It's unlikely that you see a form like this in the wild, but this is really just to demonstrate how forms are done.
Let’s include the following in this form:
- Name
- Password
- Payment Type (Radio Buttons - Visa, MasterCard, PayPal)
- Language (Dropdown - JavaScript, Python, C++)
- Agree to Terms (Checkbox)
Rendering the Form
Let's go ahead and render the form first. We'll start with the name since that is the most simple. It'll be quite simple to you've done it in HTML, with one minor difference (remember to include a label for accessibility!):
<form>
<label htmlFor="name">Name:</label>
<input type="text" id="name" name="name" placeholder="John Doe" />
</form>
In HTML, we simply use "for" while in React, you need to use htmlFor
. Alternatively, you can avoid using htmlFor
if you wrap the label
around the input
like this (although it's a good idea to still include the htmlFor:
<form>
<label>
Name:
<input type="text" name="name" placeholder="John Doe" />
</label>
</form>
Seems pretty straightforward right? Go ahead and give Email and Password a try yourself!
Radio Buttons UI
Now let's go and create the radio buttons. Don't forget to include the name
property to ensure only one radio button can be checked!
<label>
Payment Type:
<input type="radio" name="payment" value="visa" />
Visa
</label>
<label>
<input type="radio" name="payment" value="mastercard" />
Mastercard
</label>
<label>
<input type="radio" name="payment" value="paypal" />
Paypal
</label>
Dropdown UI
<fieldset>
<legend>Language</legend>
<label>
<select name="language">
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="c++">C++</option>
</select>
</label>
</fieldset>
Checkboxes UI
Now let's render the checkboxes in React:
<fieldset>
<legend>Interests</legend>
<label>
<input type="checkbox" name="interests" value="coding" />
Coding
</label>
<label>
<input type="checkbox" name="interests" value="music" />
Music
</label>
<label>
<input type="checkbox" name="interests" value="sports" />
Sports
</label>
</fieldset>
Implementing Controlled Components (handleChange)
So far, we've only built the UI to render the form elements, but we haven't actually done anything to "control" these elements. Let's go ahead and implement these controls.
The first thing we actually need is to create state to hold these form values. You may be tempted to create state for each value in the form like this:
const [name, setName] = useState("")
const [email, setEmail] = useState("")
...
However, that's not very scalable, and will get rather messy as inputs grow. It's better to store all the form values in an object like this:
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
payment: "",
interests: [],
language: "",
})
It'll make things much easier when we're trying to manage state for this form, and to pass the data later. It'll make more sense as we build the function to manage the form values.
Implementing handleChange Function
Alright, let's set up the handleChange
function step by step and break down how it is we go about controlling these form elements.
Declare the Function
function handleChange(e) {
}
The function takes an event object e
as the parameter. You can try to console.log(e)
inside the function and pass it to an input to see what it returns after hooking it up to the input:
<label>
Name:
<input
type="text"
name="name"
placeholder="John Doe"
onChange={handleChange}
/>
</label>
Anyways, the properties that we’re interested in right now is the name and value of the input that is firing. We can grab those properties and store them in a variable like this:
function handleChange(e) {
const name = e.target.name
const value = e.target.value
}
Although the above works, we can make this a little cleaner by destructuring. This is optional, it makes things a little more efficient so you don’t have to keep typing e.target.value
each time:
function handleChange(e) {
const { name, value } = e.target
}
So what we've done is extract the name and value of the target input. Next we'll need to use the name and value to update state.
Updating Text Fields
For plain text-like inputs, we store e.target.value
into the matching key in state. Note the functional state update (setFormData(prev => ...))
safely updates based on the previous state:
function handleChange(e) {
const { name, value } = e.target;
setFormData(prev => ({
...prev, // keep all the other fields
[name]: value, // update only the field that changed
}));
}
So what exactly is happening here? We're calling setFormData
, but instead of passing a new object, we pass a function that receives the previous state, which is the most recent version.
The function then returns a new state. The …prev
is a spread operator. It means “copy all the existing key-value pairs from the old state.” However, we include [name]: value
since that is the field that changed.
Radio Buttons and Dropdowns
Radio groups and dropdowns share the same name (e.g., "payment, languages"). The selected radio and dropdowns gives you its value. So good news! We can use the same logic as text inputs to store the value. All we need to do is attach the function to the inputs!
Checkboxes
This is where things a little more tricky. In this form, we're taking a boolean (true/false) for whether or not the user agrees to the terms and conditions.
So how do we store that boolean in state? Well, we would need to pull a couple other properties from the e.target
, type
and check
:
const { name, type, value, checked } = e.target
For example, if we click on the checkbox input, we'll see that it has a type of checkbox
, and the checked
value will be true or false depending on if the checkbox is checked or not.
However, if you just hook up the handleChange
function to the checkbox and check/uncheck the box, you'll see that the terms value is no longer a boolean. The browser simply sets it to a string with value "on" no matter what, which is not what we want.
So to address this, we'll need to add some logic to our function:
function handleChange(e) {
const { name, value, type, checked } = e.target
if (type === "checkbox") {
setFormData({
...formData,
[name]: checked,
})
return
}
setFormData({
...formData,
[name]: value,
})
}
Basically what we're doing is checking if the input we're working with is of the type checkbox, and if it is, we'll update the value of that input to checked
, which will be true or false. Then we break out of this function with a return so that it doesn't run setFormData again.
However, there's actually a slightly cleaner way to write this, which is:
function handleChange(e) {
const { name, value, type, checked } = e.target
setFormData((prevFormData) => {
return {
...prevFormData,
[name]: type === "checkbox" ? checked : value,
}
})
}
Instead of writing an if/else statement, we're just using a ternary.
And that will take care of the state management aspect of our form. However, there is still one little thing we're missing from our form.
Adding value
to Inputs
So far, the form looks good. It's functional, and we can see that state is updating when we update the form. However, as mentioned before, React wants to have a single source of truth. And right now, these input elements are actually managing its own internal state separately.
This may not be an issue right now, but it could lead to issues if we need to reset the form or pre-fill the form with saved data.
So to actually control the values in these inputs, we need to add a value property to these input elements:
<input
type="text"
name="name"
placeholder="John Doe"
onChange={handleChange}
value={formData.name}
/>
<label>
Payment Type:
<input
type="radio"
name="payment"
value="visa"
onChange={handleChange}
checked={formData.payment === "visa"}
/>
Visa
</label>
<label>
<input
type="checkbox"
name="terms"
onChange={handleChange}
checked={formData.terms}
/>
Agree to terms
</label>
And there you have it! You have a fully functional form with controlled components. I know this might be a lot to digest at first, but with a bit of practice, creating forms will become second nature!