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, and toasts on success.
We’ll use Next.js, Typescript, use-react-form, react-toastify, and SWR and we start by creating a new project:
$ yarn create next-app --typescript
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.
import type {NextPage} from 'next'
const Form: NextPage = () => {
return <form action="/api/form" method="POST">
<div>
<label htmlFor="email">Email</label>
<input type="email" autoComplete="email" name="email" required={true}/>
</div>
<button>Submit</button>
</form>
}
export default Form
For simplicity, I’ll hide the CSS, but here’s what this form looks like:
When you click the Submit button, you’ll be redirected to /api/form
. That’s because forms need to work for regular HTML pages (e.g. not React or any other SPA), so submitting the form isn’t done by Javascript but instead by the browser itself.
We can fix this by overwriting the onSubmit
method of the form.
const onSubmit = (e: FormEvent) => {
e.preventDefault() // Prevent the redirect
saveFormData() // TODO: how to get email?
}
return <form onSubmit={onSubmit}>
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.
const Form: NextPage = () => {
const [email, setEmail] = useState("")
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
await saveFormData({"email": email})
}
return <form onSubmit={onSubmit}>
<div>
<label htmlFor="email">Email</label>
<input type="email" autoComplete="email" name="email" required={true}
value={email} onChange={e => setEmail(e.target.value)} />
</div>
<button>Submit</button>
</form>
}
This allows us to handle the form submission, prevent the redirect, and do whatever action we want instead. Let’s make a POST request, but we’ll make it from javascript instead of a redirect and we’ll use JSON.
async function saveFormData(data: object) {
return await fetch("/api/form", {
body: JSON.stringify(data),
headers: {"Content-Type": "application/json"},
method: "POST"
})
}
Now when we hit submit, saveFormData
is called and a request is made to /api/form
. We’ll address handling errors later on.
Using react-hook-form
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:
import type {NextPage} from 'next'
import {useForm} from "react-hook-form";
const Form: NextPage = () => {
const {register, handleSubmit} = useForm();
return (
<form onSubmit={handleSubmit(saveFormData)}>
<label htmlFor="email">Email</label>
<input type="email" autoComplete="email"
{...register("email", {required: true})} />
<button>Submit</button>
</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](https://react-hook-form.com/api/useform/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:
const {register, handleSubmit, formState: {isSubmitting}} = useForm();
return (
<form onSubmit={handleSubmit(saveFormData)}>
<label htmlFor="email">Email</label>
<input type="email" autoComplete="email"
{...register("email", {required: true})} />
<button disabled={isSubmitting}>
{isSubmitting ? <Loading/> : "Submit"}
</button>
</form>
);
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:
const onSubmit = async (data: object) => {
const response = await saveFormData(data)
if (response.status === 400) {
// Validation error
} else if (response.ok) {
// successful
} else {
// unknown error
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
Now let’s handle validation errors.
const {register, handleSubmit, setError, formState: {isSubmitting, errors}} = useForm();
// ... cut for space
if (response.status === 400) {
// Validation error
// Expect response to be a JSON response with the structure:
// {"fieldName": "error message for that field"}
const fieldToErrorMessage: {[fieldName: string]: string} = await response.json()
for (const [fieldName, errorMessage] of Object.entries(fieldToErrorMessage)) {
setError(fieldName, {type: 'custom', message: errorMessage})
}
}
setError
is used to tell reaact-hook-form that something went wrong. We can then use the errors
value from formState
to get and display any errors to the user:
const {register, handleSubmit, setError, formState: {isSubmitting, errors}} = useForm();
// ... cut for space
<input type="email" autoComplete="email" {...register("email", {required: true})} />
<div className="error">{errors.email?.message}</div>
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.
// Imports
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
// in our submit handler
} else if (response.ok) {
// successful
toast.success("Successfully saved")
} else {
// unknown error
toast.error("An unexpected error occurred while saving, please try again")
}
// Later on, in our form. This can also go in higher components
// so it sticks around after this form unmounts
<form onSubmit={handleSubmit(onSubmit)}>
{/* ... */}
<ToastContainer position="bottom-center" />
</form>
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:
useForm({
defaultValues: { email: "default@example.com" }
})
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
// fetches from /api/form
const {data, error} = useSWR('/api/form', fetcher)
// New value reset
const {register, reset, handleSubmit, setError, formState: {isSubmitting, errors}} = useForm();
Then we can call reset
whenever data
changes.
useEffect(() => {
if (!data) {
return; // loading
}
reset(data);
}, [reset, data]);
Finally, we should make sure to handle the error and loading states before we return the form:
if (error) {
return <div>An unexpected error occurred while loading, please try again</div>
} else if (!data) {
return <div>Loading...</div>
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/*... and so on */}
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:
import {useEffect} from "react";
import {useRouter} from "next/router";
export function useConfirmRedirectIfDirty(isDirty: boolean) {
const router = useRouter()
// prompt the user if they try and leave with unsaved changes
useEffect(() => {
const warningText = 'You have unsaved changes - are you sure you wish to leave this page?';
const handleWindowClose = (e: BeforeUnloadEvent) => {
if (!isDirty) return;
e.preventDefault();
return (e.returnValue = warningText);
};
const handleBrowseAway = () => {
if (!isDirty) return;
if (window.confirm(warningText)) return;
router.events.emit('routeChangeError');
throw 'routeChange aborted.';
};
window.addEventListener('beforeunload', handleWindowClose);
router.events.on('routeChangeStart', handleBrowseAway);
return () => {
window.removeEventListener('beforeunload', handleWindowClose);
router.events.off('routeChangeStart', handleBrowseAway);
};
}, [isDirty]);
}
And then we just hook it up in our component:
const {register, reset, handleSubmit, setError, formState: {isSubmitting, errors, isDirty}} = useForm();
useConfirmRedirectIfDirty(isDirty)
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
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:
import {FieldValues, useForm, UseFormRegister} from "react-hook-form";
import {ToastContainer, toast} from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import useSWR from 'swr';
import React, {useEffect} from "react";
import {useUnsavedChanges} from "./useUnsavedChanges";
const fetcher = (url: string) => fetch(url).then(r => r.json())
async function saveFormData(data: object, url: string) {
return await fetch(url, {
body: JSON.stringify(data),
headers: {"Content-Type": "application/json"},
method: "POST"
})
}
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.
type Props = {
// Where to GET/POST the form data
url: string
// Function that returns a component that will display the inner form
renderForm: (formProps: FormProps) => React.ReactNode
}
// All values that come from useForm, to be used in our custom forms
export type FormProps = {
register: UseFormRegister<FieldValues>
isSubmitting: boolean
errors: { [error: string]: any }
}
And finally, the component itself:
function GenericForm({url, renderForm}: Props) {
// Fetch our initial form data
const {data, error} = useSWR(url, fetcher)
const {register, reset, handleSubmit, setError, formState: {isSubmitting, errors, isDirty}} = useForm();
// Confirm redirects when isDirty is true
useConfirmRedirectIfDirty(isDirty)
// Submit handler which displays errors + success messages to the user
const onSubmit = async (data: object) => {
const response = await saveFormData(data, url)
if (response.status === 400) {
// Validation error, expect response to be a JSON response {"field": "error message for that field"}
const fieldToErrorMessage: { [fieldName: string]: string } = await response.json()
for (const [fieldName, errorMessage] of Object.entries(fieldToErrorMessage)) {
setError(fieldName, {type: 'custom', message: errorMessage})
}
} else if (response.ok) {
// successful
toast.success("Successfully saved")
} else {
// unknown error
toast.error("An unexpected error occurred while saving, please try again")
}
}
// Sets the default value of the form once it's available
useEffect(() => {
if (data === undefined) {
return; // loading
}
reset(data);
}, [reset, data]);
// Handle errors + loading state
if (error) {
return <div>An unexpected error occurred while loading, please try again</div>
} else if (!data) {
return <div>Loading...</div>
}
// Finally, render the form itself
return <form onSubmit={handleSubmit(onSubmit)}>
{renderForm({register, errors, isSubmitting})}
<ToastContainer position="bottom-center"/>
</form>;
}
export default GenericForm
And now that we have all that code written, we can write the following form:
const Form: NextPage = () => {
const renderForm = ({register, errors, isSubmitting}: FormProps) => {
return <>
<label htmlFor="email">Email</label>
<input type="email" autoComplete="email"
{...register("email", {required: true})} />
<div className="error">{errors.email?.message}</div>
<label htmlFor="date">Date</label>
<input type="date" {...register("date", {required: true})} />
<div className="error">{errors.date?.message}</div>
<button disabled={isSubmitting}>
{isSubmitting ? <Loading/> : "Submit"}
</button>
</>;
}
return <GenericForm url="/api/form" renderForm={renderForm} />
}
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:
const Form: NextPage = () => {
const fields = [
{type: "email", name: "email", required: true, label: "Email", autoComplete: "email"},
{type: "date", name: "date", required: true, label: "Date"},
{type: "text", name: "favorite_color", required: false, label: "Favorite color"},
]
const renderForm = ({register, errors, isSubmitting}: FormProps) => {
return <>
{fields.map(field => {
return <>
<label htmlFor={field.name}>{field.label}</label>
<input type={field.type} autoComplete={field.autoComplete}
{...register(field.name, {required: field.required})} />
<div className="error">{errors[field.name]?.message}</div>
</>
})}
<button disabled={isSubmitting}>
{isSubmitting ? <Loading/> : "Submit"}
</button>
</>;
}
return <GenericForm url="/api/form" renderForm={renderForm} />
}
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
.
Top comments (0)