Forms are everywhere. Settings pages, comments sections, user profiles - they're all backed by forms.
In this guide, we’ll create a form that we can use across our product. We’ll start with something simple and slowly add in more complexity like:
- Preventing the user from navigating away with unsaved changes
- Different types of error handling
- Showing a spinner when submitting
- Toasts on success
- Easily extensible schema
Ultimately, the code we will write in the end looks like this:
But to get there, let's start off with just a basic form.
Building a basic form
We’ll start with building the simplest possible form. If you already are familiar with a basic React form, skip ahead.
For simplicity, I’ll hide the CSS, but here’s what this form looks like:
We can fix this by overwriting the onSubmit method of the form.
But this leads to another question: how do we get the data from the form to submit? For this, we can use the useState hook and switch to a controlled component.
Now when we hit submit, saveFormData is called and a request is made to /api/form. We’ll address handling errors later on.
Our current form is pretty simple, but over time we’re going to want to add schema validation, display a submitting spinner, have more complicated state, etc. react-hook-form will reduce a lot of boilerplate for us. Here’s the same example as above written with react-hook-form:
There are two key things to note here:
- Each input field needs to be registered which will add the name tag and manage the value for us.
- The form state is managed for us and passed in automatically to saveFormData.
Adding a loading spinner when submitting
We saw above that useForm returned register which we used to register our fields and handleSubmit which we used to make a JSON HTTP request.
It also has formState which you can use to get information about the form. One simple example is isSubmitting which we can use disable the Submit button and add a loading spinner:
Handling validation errors with react-hook-form
There are two primary types of errors we’ll worry about here:
- Validation errors - Things like "we required a field with at least 8 characters but yours has 7"
- Unexpected errors - Things like network timeouts, generic server errors, etc.
Validation logic can be checked client side with libraries like yup, but you should also always validate requests on the server. We can extend our submit function to check for errors like so:
Now let’s handle validation errors.
setError is used to tell react-hook-form that something went wrong. We can then use the errors value from formState to get and display any errors to the user:
Errors set by setError will automatically be reset when the user submits the form again so this is everything we need to do for validation errors.
Handling unexpected errors with react-toastify
Unexpected errors are a little different. They are hopefully temporary and each page might want to display them differently. To make something that works on any page, we can use react-toastify to display a generic error message in a toast.
Since a toast is pretty generic, we can also use it to display a successful message when our form was successfully saved.
Loading default state asynchronously
So far, we’ve only ever dealt with having the user fill out the form and submit it. For some forms, like on a settings page, we’ll likely want to fetch the existing values and pre-populate the form.
One way to do this is to provide a defaultValues value to the useForm hook:
This works fine, but sometimes you need to fetch the default values from an API. You can read more about it asynchronous defaultValues here, but the gist of the solution is to use reset which is returned from useForm and allows you to set new defaultValues.
We’ll use SWR for fetching data, as it provides a simple interface with a lot of boilerplate removed, and we’ll assume that we need to make a GET request to /api/form
Then we can call reset whenever data changes.
Finally, we should make sure to handle the error and loading states before we return the form:
Preventing redirects with unsaved form data
The last feature that we want is to prevent people from leaving the page if they have unsaved form data. Unsurprisingly, useForm provides some help here too, with an isDirty boolean indicating if the user has changed the form from the default state.
This Github issue describes the problem in depth and we can pull the code from that ticket out and turn it into a hook:
And then we just hook it up in our component:
Refactoring to make the form reusable
So far, we’ve made one form that does the following:
- Fetches the initial state from an API
- Makes a POST request on submit
- Disables the submit button and shows a spinner when submitting
- Handles validation errors, unexpected errors, and displays a success message
- Confirms the user wants to leave if they have unsaved data, on redirect/refresh
But the form itself is just an email address. Let’s put everything together and make it generic so that we can display any number of fields.
To start, here are the imports and helper functions:
Next we have the types for our component. renderForm is a function that will take in a few values that we need when we are rendering the fields of a form, and should return those fields.
And finally, the component itself:
And now that we have all that code written, we can write the following form:
which asks for not only an email address but also a date! When you click submit, the JSON that gets submitted includes both email and date fields.
You can get more complex obviously, and maybe you even want to write it like this:
No matter how you write it, we’ve now made a pretty advanced form that we can use all over our application.
A note on the assumptions in this form
One quick disclaimer, this form is something I would call an internally useful abstraction. We’ve made a few assumptions about how forms work that may not apply to every product. As an example, maybe you don’t want to display a toast on success but do something else.
I find abstractions like this nice because you can customize it to your application and re-use it so that your users get a consistent experience. If you need more customizability, you can always add another prop like onSuccess to the GenericForm.