React Forms
February 27, 2020
The goals of this lecture are to:
- Build forms in React
- Understand what Controlled Components are
Forms
HTML form elements work differently than other elements within React.
Form elements naturally keep some internal state, without any additional help on our part.
We've seen this in the past when working with vanilla JavaScript in the DOM: when a user enters an input value, we can reach into that input and grab the value stored in that element's internal state.
Say we have the following form element:
<form>
<label for="fullName">Full Name:</label>
<input name="fullName" />
<button>Add</button>
</form>
It would be convenient to have a JavaScript function that both has access to the data the user entered and handles the submission of the form.
We'll accomplish this end using what is referred to as controlled components.
Controlled Components
As we pointed out above, form elements in HTML (like <input>
, <textarea>
, and <select>
) maintain their own state and update it based on the user's input.
In React, mutable state is kept in the state of a component and only updated with the function returned to useState()
.
How can we use React to control the form's input state?
Single Source of Truth
In React, we want to make our state the "single source of truth."
In the context of forms, React would then control not only what is shown in the input (the value of the component), but also handle what happens when the user changes the input.
With control established over both aspects of an element (the display and the handling of the input changes), we have created what we refer to as a “controlled component”.
How Controlled Forms Work
Let's take a look at an example form:
<form>
<label htmlFor="firstName">First Name:</label>
<input id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange} />
</form>
Since the value
attribute of our input is programatically set, the displayed value at any given moment will be what formData.firstName
is set to in the component's state.
Meanwhile, in our component, our formData
and handleChange
function is defined as follows:
const NameForm = () => {
// ...
const [formData, setFormData] = useState({
firstName: "",
lastName: ""
});
const handleChange = (evt) => {
setFullName(evt.target.value);
}
// ...
}
handleChange
will run on every keystroke that the user makes, updating the value of firstName
in the component's state. As a result, the displayed value will update as the user types.
With a controlled component, every state mutation will have an associated handler function. This makes it easy to modify or validate user input.
A Note on Labels
Labels (<label>
) play an important role in forms: when associated with an input element that has an id
with the same value, a user can click on the label and the corresponding input will be autofocused.
Because we are working in JavaScript, for
is a reserved keyword. This means that we lose the ability to use for
on our JSX elements.
Instead, we need to use the htmlFor
attribute in place of for
, just like we have to use className
for class
.
If you forget this, the console will warn you accordingly.
Handling Multiple Inputs
Before we look at how to elegantly handle multiple form inputs, let's first revisit some ES6 functionality.
Computed Property Names
In ES6, we got some new exciting new object enchancements, one of which included the ability to create objects with dynamic keys.
This feature is called computed property names.
To demonstrate this, take a look at how we'd create a dynamic object key in ES5:
var instructorData = {};
var instructorCode = "elie";
instructorData[instructorCode] = "Elie Schoppik";
Using ES6, we can now accomplish the same in a more readable way:
let instructorCode = "elie";
let instructorData = {
// propery computed inside the object literal
[instructorCode]: "Elie Schoppik"
};
Applying Computed Property Names to React Forms
Rather than making a separate onChange
handler for every single input in our form, we can take advantage of computed property names and make a generic function that can be used for multiple inputs.
We'll start by adding the HTML name attribute to each JSX input element and
let the handler function decide the corresponding key in the component state to update using the event.target.name
value:
const [formData, setFormData] = useState({
firstName: "",
lastName: ""
});
const handleChange = evt => {
const { name, value } = evt.target;
setFormData(fData => ({
...fData,
[name]: value
}));
};
In the code above, you can see that when the onChange
event is triggered, that event is passed to our handleChange
event.
We are then, by way of destructuring, reaching into that evt.target
and pulling out the name
and value
property values.
Finally, we are using that key and value to overwrite the existing key/value pair and updating the formData
object in our component's state.
Passing Data Upward to a Parent Component
In React, we generally have downward data flow: parent components pass information down to simpler, child components.
That said, it is also common for form components to manage their own state.
So, what's the correct design choice here?
Often, we'll define a dedicated onSubmit
function within our parent component, which we'll then pass down as a prop to our child component to use. This function will be connected to and update the parent's state when it's called.
In the child component, when the form is submitted and the onSubmit
event is triggered, this passed function will be called.
With this setup, our child component remains sufficiently "dumb", knowing only to pass its own data into the function that was handed down from its parent.
Example: Shopping List
To illustrate the above, let's take a look at an example.
Say we have a shopping list application that features a ShoppingList
and an AddItemForm
, used to add items to the list.
In our ShoppingList
, we have defined the function that will be passed down to our AddItemForm
:
const addItem = item => {
let newItem = { ...item, id: uuid() };
setItems(items => [...items, newItem]);
};
In the child form component, AddItemForm
, we grab that function and associated it with the onSubmit
attribute on our form.
When the form is submitted, we pass in the formData
from our state, which will then be used in the parent component, ShoppingList
, to update the items on the list:
const handleSubmit = evt => {
evt.preventDefault();
addItem(formData);
setFormData(INITIAL_STATE);
};
Keys and UUIDs
We've learned that, generally speaking, using an index as a key prop isn't a great idea.
But what do we do if there is no natural, unique key available for use?
In this case, we'll reach for a utility that create a Universally Unique Identifier (UUID).
To install in our project, we just run:
$ npm install uuid
To use the uuid
module, we first import it and then call it in the place we'd like a unique id to be created:
import uuid from "uuid/v4";
/** Add new item object to cart. */
const addItem = item => {
let newItem = { ...item, id: uuid() };
setItems(items => [...items, newItem]);
};
Uncontrolled Components
Anytime that React is not in control of the state of an input, that input is known as an uncontrolled component.
Some inputs and external libraries will require this.
Validation
Validation plays a big role in a positive user experience. Users want to know if their actions are getting them closer to their intended outcome, and, if not, exactly what they need to adjust in order to reach those ends.
Form validation or any other validation on the UI is not a substitute for server-side validation. It is purely to create a better, more helpful experience for the user.
The utility that we recommend using for this purpose is Formik.
Testing Forms
To test typing into form inputs, we use fireEvent.change
.
When we use this, we'll need to mock the evt.target.value
in order to tell the React testing library what to place in the input.
For controlled components, the state will automatically update.
For example:
it("can add a new item", function() {
const { getByLabelText, queryByText } = render(<ShoppingList />);
// no items yet
expect(queryByText("ice cream: 100")).not.toBeInTheDocument();
const nameInput = getByLabelText("Name:");
const qtyInput = getByLabelText("Qty:");
const submitBtn = queryByText("Add a new item!")
// fill out the form
fireEvent.change(nameInput, { target: { value: "ice cream" }});
fireEvent.change(qtyInput, { target: { value: 100 }});
fireEvent.click(submitBtn);
// item exists!
expect(queryByText("ice cream: 100")).toBeInTheDocument();
});