Dev

Creating advanced reusable forms in Next.js

May 9, 2022

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

We’ll use Next.js, Typescript, React Hook Form, react-toastify, and SWR and we start by creating a new project:

$ yarn create next-app --typescript

Ultimately, the code we will write in the end looks 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} />
}

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.

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

  1. Each input field needs to be registered 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 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:

  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, 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 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:

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
  • 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:

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.

Subscribe for updates

Get started for free

PropelAuth's free plan lets you go live and start signing up users (no credit card required)