Controlled Forms in React
Controlled form inputs are a core React concept that can be confusing for programmers new to the framework. Along with quirks like props and hooks, it’s one of the few nuances a web developer needs to understand before bringing even a basic React app to life.
Why do we need controlled components in the first place? Mutable state in React is typically kept in the state property of components and can only be changed using setState() or the useState() hook. HTML form elements such as <input> or <select>, however, also hold internal state — they track and save the information a user is entering. A controlled component is our way around this Catch-22.
Simply put, a controlled form ties its input values to a component’s state. When a user enters information, it also updates the state. The internal state of the form inputs and the higher-level component state are no longer different; they’re the same, a single source of truth. It’s one of those things that’s much easier to grasp when you see it in action, so let’s break down some code!
Get started by creating a simple LoginForm.js component with a form and inputs for username and password.
import React from 'react'; export default function LoginForm() {
return (
<form className="login-form">
<input name="username" type="text" />
<input name="password" type="password" />
<input type="submit" value="Submit" />
</form>
)
}
Next, since this is a functional component, let’s bring in state using the useState hook. You’ll need to establish pieces of empty state for each input. In this case, that’s username and password. (Note: Don’t forget to import useState.)
import React, { useState } from 'react';export default function LoginForm() { const [formData, setFormData] = useState({
username: '',
password: ''
}) return (
<form className="login-form">
<input name="username" type="text" />
<input name="password" type="password" />
<input type="submit" value="Submit" />
</form>
)
}
You can name the state whatever you like. I like formData for the symmetry with vanilla JavaScript, but use whatever convention makes the most sense to you. The next step is to add a value attribute to each input, assigned to the corresponding piece of state. In easier terms, just put value={formData.username} on the username input and value={formData.password} on the password input. This will initialize the form with blank fields, as they’re pulling their data from the empty strings defined in state.
import React, { useState } from 'react';export default function LoginForm() { const [formData, setFormData] = useState({
username: '',
password: ''
}) return (
<form className="login-form">
<input
name="username"
type="text"
value={formData.username}
/>
<input
name="password"
type="password"
value={formData.password}
/>
<input type="submit" value="Submit" />
</form>
)
}
If you have your server running, you’ll notice something a little problematic. You can’t actually type anything in the form inputs! That’s because we’re trying to mutate state in a way that React doesn’t allow. To get around this, we have to add an onChange function to the inputs. It will track changes and mutate state on the fly as a user is entering information.
Let’s start by creating a handleChange function above the return statement. This should seem familiar if you’ve already used functions like handleSubmit or handleClick in vanilla JavaScript. The handleChange function accomplishes only one thing — it sets the state of formData as a user inputs information. Since we’re handling multiple inputs at once with this same function, we’ll want to use event.target to extrapolate and assign the data as well as the …formData spread operator to ensure the other pieces of state remain in place while you’re manipulating the various inputs.
import React, { useState } from 'react';
export default function LoginForm() { const [formData, setFormData] = useState({
username: '',
password: ''
}) const handleChange = (event) => {
setFormData({
...formData,
[event.target.name]: event.target.value
})
} return (
<form className="login-form">
<input
name="username"
type="text"
value={formData.username}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={formData.password}
onChange={handleChange}
/>
<input type="submit" value="Submit" />
</form>
)
}
Now, if you open your DevTools and watch this component’s state, you’ll see it initialized with empty strings. As a user types information into the inputs — their username and password — you’ll notice the state updating in real time. Mission accomplished! That’s a controlled form. React will now stop yelling at you in bright red text in your console.
There’s only one thing we’re missing: a handleSubmit function. This tutorial will end here, as what you’ll want to do with that data will vary depending on your project. It’s worth noting that, if you handle the formData state as I did above, the formData object is ready as-is to be sent to a backend for a POST or PATCH request. Here’s what the final code for this login form would look like:
import React, { useState } from 'react';
export default function LoginForm() { const [formData, setFormData] = useState({
username: '',
password: ''
}) const handleChange = (event) => {
setFormData({
...formData,
[event.target.name]: event.target.value
})
} const handleSubmit = (event) => {
event.preventDefault()
console.log("Your move, dear reader.")
}return (
<form className="login-form" onSubmit={handleSubmit}>
<input
name="username"
type="text"
value={formData.username}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={formData.password}
onChange={handleChange}
/>
<input type="submit" value="Submit" />
</form>
)
}
Thanks for reading! Happy hacking.