Almost every application needs to accept user input at some point, and this is usually achieved with the venerable HTML form and its collection of input controls. If you’ve recently started learning React, you’ve probably arrived at the point where you’re now thinking, “So how do I work with forms?”
This article will walk you through the basics of using forms in React to allow users to add or edit information. We’ll look at two different ways of working with input controls and the pros and cons of each. We’ll also take a look at how to handle validation, and some third-party libraries for more advanced use cases.
Uncontrolled Inputs
The most basic way of working with forms in React is to use what are referred to as “uncontrolled” form inputs. What this means is that React doesn’t track the input’s state. HTML input elements naturally keep track of their own state as part of the DOM, and so when the form is submitted we have to read the values from the DOM elements themselves.
In order to do this, React allows us to create a “ref” (reference) to associate with an element, giving access to the underlying DOM node. Let’s see how to do this:
class SimpleForm extends React.Component {
constructor(props) {
super(props);
// create a ref to store the DOM element
this.nameEl = React.createRef();
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(e) {
e.preventDefault();
alert(this.nameEl.current.value);
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>Name:
<input type="text" ref={this.nameEl} />
</label>
<input type="submit" name="Submit" />
</form>
)
}
}
As you can see above, for a class-based component you initialize a new ref in the constructor by calling React.createRef
, assigning it to an instance property so it’s available for the lifetime of the component.
In order to associate the ref with an input, it’s passed to the element as the special ref
attribute. Once this is done, the input’s underlying DOM node can be accessed via this.nameEl.current
.
Let’s see how this looks in a functional component:
function SimpleForm(props) {
const nameEl = React.useRef(null);
const handleSubmit = e => {
e.preventDefault();
alert(nameEl.current.value);
};
return (
<form onSubmit={handleSubmit}>
<label>Name:
<input type="text" ref={nameEl} />
</label>
<input type="submit" name="Submit" />
</form>
);
}
There’s not a lot of difference here, other than swapping out createRef
for the useRef
hook.
Example: login form
function LoginForm(props) {
const nameEl = React.useRef(null);
const passwordEl = React.useRef(null);
const rememberMeEl = React.useRef(null);
const handleSubmit = e => {
e.preventDefault();
const data = {
username: nameEl.current.value,
password: passwordEl.current.value,
rememberMe: rememberMeEl.current.checked,
}
// Submit form details to login endpoint etc.
// ...
};
return (
<form onSubmit={handleSubmit}>
<input type="text" placeholder="username" ref={nameEl} />
<input type="password" placeholder="password" ref={passwordEl} />
<label>
<input type="checkbox" ref={rememberMeEl} />
Remember me
</label>
<button type="submit" className="myButton">Login</button>
</form>
);
}
While uncontrolled inputs work fine for quick and simple forms, they do have some drawbacks. As you might have noticed from the code above, we have to read the value from the input element whenever we want it. This means we can’t provide instant validation on the field as the user types, nor can we do things like enforce a custom input format, conditionally show or hide form elements, or disable/enable the submit button.
Fortunately, there’s a more sophisticated way to handle inputs in React.
Controlled Inputs
An input is said to be “controlled” when React is responsible for maintaining and setting its state. The state is kept in sync with the input’s value, meaning that changing the input will update the state, and updating the state will change the input.
Let’s see what that looks like with an example:
class ControlledInput extends React.Component {
constructor(props) {
super(props);
this.state = { name: '' };
this.handleInput = this.handleInput.bind(this);
}
handleInput(event) {
this.setState({
name: event.target.value
});
}
render() {
return (
<input type="text" value={this.state.name} onChange={this.handleInput} />
);
}
}
As you can see, we set up a kind of circular data flow: state to input value, on change event to state, and back again. This loop allows us a lot of control over the input, as we can react to changes to the value on the fly. Because of this, controlled inputs don’t suffer from the limitations of uncontrolled ones, opening up the follow possibilities:
- instant input validation: we can give the user instant feedback without having to wait for them to submit the form (e.g. if their password is not complex enough)
- instant input formatting: we can add proper separators to currency inputs, or grouping to phone numbers on the fly
- conditionally disable form submission: we can enable the submit button after certain criteria are met (e.g. the user consented to the terms and conditions)
- dynamically generate new inputs: we can add additional inputs to a form based on the user’s previous input (e.g. adding details of additional people on a hotel booking)
Validation
As I mentioned above, the continuous update loop of controlled components makes it possible to perform continuous validation on inputs as the user types. A handler attached to an input’s onChange
event will be fired on every keystroke, allowing you to instantly validate or format the value.
Example: credit card validation
Let’s take a look at a real-word example of checking a credit card number as the user types it into a payment form.
The example uses a library called credit-card-type to determine the card issuer (such as Amex, Visa, or Mastercard) as the user types. The component then uses this information to display an image of the issuer logo next to the input:
import creditCardType from "credit-card-type";
function CreditCardForm(props) {
const [cardNumber, setCardNumber] = React.useState("");
const [cardTypeImage, setCardTypeImage] = React.useState(
"card-logo-unknown.svg"
);
const handleCardNumber = (e) => {
e.preventDefault();
const value = e.target.value;
setCardNumber(value);
let suggestion;
if (value.length > 0) {
suggestion = creditCardType(e.target.value)[0];
}
const cardType = suggestion ? suggestion.type : "unknown";
let imageUrl;
switch (cardType) {
case "visa":
imageUrl = "card-logo-visa.svg";
break;
case "mastercard":
imageUrl = "card-logo-mastercard.svg";
break;
case "american-express":
imageUrl = "card-logo-amex.svg";
break;
default:
imageUrl = "card-logo-unknown.svg";
}
setCardTypeImage(imageUrl);
};
return (
<form>
<div className="card-number">
<input
type="text"
placeholder="card number"
value={cardNumber}
onChange={handleCardNumber}
/>
<img src={cardTypeImage} alt="card logo" />
</div>
<button type="submit" className="myButton">
Login
</button>
</form>
);
}
The input’s onChange
handler calls the creditCardType()
function with the current value. This returns an array of matches (or an empty array) which can be used to determine which image to display. The image URL is then set to a state variable to be rendered into the form.
You can use some of the numbers from here to test the input.
Form Libraries
As you might have noticed, there’s a certain amount of boiler-plate code when working with forms, especially having to wire up the inputs with their state values and handlers. As you might expect, there are a variety of third-party libraries out there to help take the pain out of dealing with larger and more complex forms.
To give you some idea of what using a form library is like, let’s take a quick look at one called Fresh. The aim of this library is to cover 90% of your common use cases with a simple and easy-to-use API. Here’s an example of a profile editing form you might find in a web app:
import { Form, Field } from "@leveluptuts/fresh";
const securityQuestions = [
"What is your mother's maiden name?",
"What was the name of your first pet?",
"What was the name of your first school?"
];
const handleSubmit = (data) => console.log(data);
function UserProfileForm() {
return (
<Form formId="user-profile" onSubmit={handleSubmit}>
<Field required>First Name</Field>
<Field required>Last Name</Field>
<Field required type="email">
Email
</Field>
<Field required type="select" options={securityQuestions}>
Security Question
</Field>
<Field required>Security Answer</Field>
<Field type="textarea">Bio</Field>
</Form>
);
}
Fresh provides some custom components to make creating forms very straightforward. The Field
component takes care of wiring up data binding on the form inputs, converting the label you provide into a camel-case property name for the input’s value. (For example, “Last Name” becomes lastName
in the form’s state.)
The Form
component wraps all the fields, and takes an onSubmit
callback which receives the form data as an object. Below is an example of the output from the form:
{
firstName: "Bill",
lastName: "Gates",
email: "billg@microsoft.com",
securityQuestion: "What was the name of your first pet?",
securityAnswer: "Fluffy",
bio: "Bill Gates is a technologist, business leader, and philanthropist. He grew up in Seattle, Washington, with an amazing and supportive family who encouraged his interest in computers at an early age."
}
As you can see, libraries like this can really speed up working with forms and make your components much less verbose. For anything more than a basic, small form, I’d recommend choosing one that fits your needs, as it will save you time in the long run.
Conclusion
You should now have a solid understanding of how forms and inputs can be used within React. You should know the difference between controlled and uncontrolled inputs, and the pros and cons of each, being aware that the tight update loop of a controlled input allows you a lot of options for formatting and validating the values on the fly. Lastly, you should be aware that there are form libraries available that prevent you from having to add a lot of repetitive and verbose boilerplate code to your React forms, which will help you be a more productive developer.