Creating advanced reusable forms in Next.js
Tags: nextjs, beginners, webdev, javascript
Published: 2022-05-09 18:31:37

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:

```jsx
$ 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.

```jsx
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:

![basic form](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zcaeujoxd6pun8j8lk68.png)

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. 

```jsx
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](https://reactjs.org/docs/forms.html#controlled-components).

```jsx
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.

```jsx
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](https://react-hook-form.com/) will reduce a lot of boilerplate for us. Here’s the same example as above written with react-hook-form:

```jsx
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:

1. Each input field needs to be [registered](https://react-hook-form.com/api/useform/register) which will add the name tag and manage the value for us.
2. 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:

```jsx
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>
);
```

![submit with spinner](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m1q4e2gtgs54q6bha6a8.gif)

### Handling validation errors with react-hook-form

There are two primary types of errors we’ll worry about here:

1. **Validation errors** - Things like "we required a field with at least 8 characters but yours has 7"
2. **Unexpected errors** - Things like network timeouts, generic server errors, etc.

Validation logic can be checked client side with libraries like [yup](https://github.com/jquense/yup), but you should also always validate requests on the server. We can extend our submit function to check for errors like so:

```jsx
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.

```jsx
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:

```jsx
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.


![form with error](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zhwctn4wwsnwjfxzvm2n.png)

### 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](https://www.npmjs.com/package/react-toastify) to display a generic error message in a toast.

![form error with toast](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v4mo9jwc4hfgfntai0a5.gif)

```jsx
// 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:

```jsx
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](https://github.com/react-hook-form/react-hook-form/issues/2492), 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](https://swr.vercel.app/) ****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`

```jsx
// 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.

```jsx
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:

```jsx
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

![confirm redirect](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2vzwg4yrc8nf540hll13.png)

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](https://github.com/vercel/next.js/issues/2694) describes the problem in depth and we can pull the code from that ticket out and turn it into a hook:

```jsx
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:

```jsx
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:

```jsx
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.

```tsx
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:

```jsx
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:

```tsx
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:

```tsx
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`.