Debounce Form Inputs with React Hook Form

Ben OrloffBen Orloff
17 min read

Intro

First of all, what exactly does debouncing mean?

Debouncing is a programming practice used to ensure that time-consuming tasks do not fire so often, that it stalls the performance of the web page. In other words, it limits the rate at which a function gets invoked.

Source: GeeksForGeeks

The vast majority of web forms will function just fine without debounced values. For example, a basic contact form with name, email, subject, and message fields most likely doesn't need to debounce any of the field values. A form like this will probably execute validation onSubmit to check whether all of the required fields have values and maybe even check whether the email value is a valid email address.

So, when does it make sense to use debounced values in a form?

Use Cases

In the context of forms, debounced values are most often used in situations where the value of a field needs to be validated by an asynchronous function. Below are a couple of scenarios in which it might make sense to implement debouncing. Of course, this is not an exhaustive list by any means. Ultimately, it is your prerogative to determine whether debouncing values is appropriate for your particular use case.

Scenario #1: Checking the Availability of a Username

Let's consider a typical user onboarding flow during which the user is prompted to select a username for their new account. Obviously, we don't want multiple users to have the same username, so we'll need to verify that the username in question hasn't already been taken by someone else before we create the new account.

This would probably involve querying a database and awaiting a response. It might look something like this:

const checkUsername = async ( 
    username: string 
): Promise<boolean> => {
    // Query the DB to check is already a user
    // with this username
    const user = await db.user.findUnique({
        where: {
            username
        }
    });
    // If no user is found with this username,
    // return true to indicate it is available
    if (!user) {
        return true;
    };
    // If a user is found with this username, 
    // return false to indicate it is not available
    return false;
};

Scenario #2: Performing an HTTP Request

If your form collects a URL, then you may want to validate some aspect of the website to determine its validity. As an example, let's say we want to determine whether the website is built with WordPress. The easiest way to do this would be to send a HEAD request to the URL and check if the response contains the Link header that the WordPress REST API adds to all front-end pages.

๐Ÿ’ก
HEAD requests are generally considered safe to send blindly since their responses should not contain a body. They should only return the headers that would have been returned if the request had been sent with the GET method.

This request and corresponding validation might look something like this:

const checkURL = async (
    url: string
): Promise<boolean> => {
    let isWordPress: boolean = false;
    // Send a HEAD request to the supplied URL
    try {
        const res = await fetch(url, {
            method: "HEAD",
        });
        const headerLink = res.headers.get("link") || undefined;
        // If the Link header contains "https://api.w.org"
        // update isWordPress to true
        if (headerLink?.includes("https://api.w.org")) {
            isWordPress = true;
        };
    } catch (error) {
        console.log("Error parsing response headers: ", error):
    };
    return isWordPress;
}

Both of these scenarios involve validating the input with an asynchronous function. If we want to display the validation result to the user while the input element still has focus, then we'll need to validate onChange. However, this means the async function is going to be invoked with each new keystroke.

๐Ÿ’ก
Although the useForm hook from react-hook-form does ship with a delayError property, this only delays the display of error messages to the end user. It does not delay validation.

Besides the fact that the input value may have already changed by the time the async promise is resolved (which could result in UI issues), this approach also causes the server to deal with an unnecessary amount of function invocations:

The validation function is invoked on each keystroke

That's where debouncing comes in.

By debouncing our input value, we can specify an amount of time that must elapse since the last change event before the validation is executed.

Now that we've established why we're using debounced, let's see how we use it.

How to Implement Debounce

As with most things in programming, there is more than one way to debounce. But the core concept is always the same โ€” applying a delay to a client-side interaction.

In this project, we're going to be using a library called usehooks-ts (Website / NPM) which comes with around 30 ready-to-use hooks written in Typescript. Another great library is use-debounce (NPM); however, it is not written in Typescript.

๐Ÿ’ก
Before using an external library in your project, do your research and perform the necessary due diligence โ€” especially if you are writing code that will be used in a production environment. Verify that the library is actively maintained, has a significant user base, is well-documented, and has a repository that follows best practices.

If you don't want to use an external library, you can of course write your own debounce code using native React hooks. For example, your code might look something like this:

// Example form component

export const Form = () => {

    // Add the input as a state variable
    const [inputValue, setInputValue] = useState<string>("");
    // Add the debounced input as a state variable
    const [debouncedInputValue, setDebouncedInputValue] = useState<string>("");

    // Set inputValue on each change
    const handleChange = (
        e: React.ChangeEvent<HTMLInputElement>
    ) => {
        const { value } = e.target;
        setInputValue(value);
    };

    // This runs when the inputValue is mounted
    // and every time inputValue changes
    useEffect(() => {
        // Ensure that debouncedInputValue updates at most
        // once every 500ms
        const delay = setTimeout(() => {
            setDebouncedInputValue(inputValue);
        }, 500);
        // Return a cleanup function
        return () => clearTimeout(delay);
    }, [inputValue];

    return (
        // ... 
            <input 
                type="text" 
                value={inputValue}
                onChange={handleChange}
            />
        // ...
    )
}

In this example, you would have access to a state variable named debouncedInputValue that is updated at most once every 500ms. You can use this debounced value however you'd like from there โ€” pass it as an argument to a function, perform a database query, etc.

But this just an example. As I mentioned earlier, we're going to be using usehooks-ts to debounce.

Let's do that now.

Starter Code

If you haven't been following along with this series, here's where we left off in the last article:

@/app/get-started/page.tsx

// @/app/get-started/page.tsx

import { SiteForm } from "@/components/site-form";

const GetStartedPage = () => {
    return (
        <main className="flex min-h-screen flex-col items-center justify-between p-24">
            <div className="z-10 max-w-5xl w-full items-center justify-center lg:flex space-y-4">
                <SiteForm />
            </div>
        </main>
    )
}

export default GetStartedPage;

@/components/site-form.tsx

// @/components/site-form.tsx

"use client"

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { checkUrl } from "@/lib/wordpress";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { 
    Form, 
    FormControl, 
    FormField, 
    FormItem, 
    FormLabel, 
    FormMessage 
} from "@/components/ui/form";
import { 
    Card, 
    CardContent, 
    CardDescription, 
    CardFooter, 
    CardHeader, 
    CardTitle 
} from "@/components/ui/card";

const httpRegex = /^(http|https):/
const completeUrlRegex = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/

const FormSchema = z.object({
    name: z
        .string()
        .min(1, {
            message: "Please enter a name for your site."
        })
        .max(255, {
            message: "Name must be less than 255 characters."
        }),
    url: z
        .string()
        .min(1, {
            message: "Please enter a URL for your site."
        })
        .max(255, {
            message: "URL must be less than 255 characters."
        })
        .transform((val, ctx) => {
            let completeUrl = val;
            // Prepend https:// if the URL 
            // doesn't start with http:// or https:// 
            if (!httpRegex.test(completeUrl)) {
                completeUrl = `https://${completeUrl}`;
            }
            // If the URL is still invalid, display an error message
            // and pass the fatal flag to abort the validation process early
            // This prevents unnecessary requests to the server to check
            // if the URL is a WordPress site
            if (!completeUrlRegex.test(completeUrl)) {
                ctx.addIssue({
                    code: z.ZodIssueCode.custom,
                    fatal: true,
                    message: "Please enter a valid URL",
                });
                return z.NEVER;
            }
            return completeUrl;
        })
        // This refinement checks if the URL is a WordPress site
        // It only runs if the URL is valid
        .refine(async (completeUrl) => 
            completeUrl && await checkUrl(completeUrl), { 
                message: "Uh oh! That doesn't look like a WordPress site.",
        })
});

export const SiteForm = () => {

    const form = useForm<z.infer<typeof FormSchema>>({
        resolver: zodResolver(FormSchema),
        defaultValues: {
            name: "",
            url: "",
        },
        mode: "onChange"
    })

    const { 
        setValue,
        handleSubmit,
        control, 
    } = form;

    const handleChange = (
        e: React.ChangeEvent<HTMLInputElement>,
        fieldName: keyof z.infer<typeof FormSchema>
    ) => {
        const { value } = e.target;
        setValue(fieldName, value, { shouldDirty: true, shouldValidate: true });
    }; 

    const onSubmit = (values: z.infer<typeof FormSchema>) => {
        console.log(values, "values")
    }

    return (
        <Form {...form}>
            <form onSubmit={handleSubmit(onSubmit)}>
                <Card className="w-[350px]">
                    <CardHeader>
                        <CardTitle>Create Your Website</CardTitle>
                        <CardDescription>Tell us about your new site to get started.</CardDescription>
                        <CardContent className="py-6 px-0 space-y-4">
                            <FormField 
                                control={control}
                                name="name"
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>Name</FormLabel>
                                        <FormControl>
                                            <Input 
                                                {...field}
                                                onChange={(e) => handleChange(e, field.name)}
                                            />
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />
                            <FormField 
                                control={control}
                                name="url"
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>URL</FormLabel>
                                        <FormControl>
                                            <Input 
                                                {...field} 
                                                onChange={(e) => handleChange(e, field.name)}
                                            />
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />
                        </CardContent>
                        <CardFooter className="justify-end py-0 px-0">
                            <Button>Get Started</Button>
                        </CardFooter>
                    </CardHeader>
                </Card>
            </form>
        </Form>
    )
}

@/lib/wordpress.ts

// @/lib/wordpress.ts

"use server"

export const checkUrl = async (url: string): Promise<boolean> => {  
    // Initialize as falsy
    let isWordPress: boolean = false;

    try {
        // Send a HEAD request to the provided URL
        const response = await fetch(url, {
            method: "HEAD",
        })
        // Check the headers for the presence of the WordPress API
        // Further checks will be needed to determine if the site is WordPress
        // when the Rest API is disabled
        const headerLinks = response.headers.get("link");
        if (headerLinks?.includes("https://api.w.org")) {
            isWordPress = true;
        }
    } catch (error) {
        console.log("Error parsing response headers: ", error);
    }

    // Return the result as a boolean
    return isWordPress!!;
}

The rest of the project consists of the default folders and files you get when you create a Next.js app using npx create-next-app@latest

Install Dependency

Since we're using an NPM package, we'll have to install it in our project. In your Terminal, navigate to the project directory and then run this command:

npm i usehooks-ts

Debounce The Input Value

Now that we have installed the usehooks-ts package in our project, we can get started on implementing the debounce.

The useDebounceCallback Hook

Of the many hooks that usehooks-ts provides, the one we'll be using today is named useDebounceCallback (Documentation).

This hook creates a debounced version of a callback function, which is exactly what we want to do. We don't want to debounce the input value directly because that would cause a delay between what the user is typing and what they are seeing โ€” which would not be a good user experience.

In your form component (if you've been following this series from the beginning, this will be located at @/components/site-form.tsx), add the hook to your imports:

// @/components/site-form.tsx

// ...your existing imports
import { useDebounceCallback } from "usehooks-ts"

// ...the rest of your code

Add State & Hook to the Form Component

Since we are going to be keeping track of the debounced input value separately from the current input value (which is being handled by react-hook-form), we'll need to add a state variable for this. We'll also need to initialize our useDebounceCallback hook.

While we're here, let's also add another state variable isTyping that will come in handy for the bonus section.

// @/components/site-form.tsx

import { useState } from "react";
// ...your existing imports 

// ...regex

// ...FormSchema 

export const SiteForm = () => {

    const [urlValue, setUrlValue] = useState<string>("");
    const [isTyping, setIsTyping] = useState<boolean>(false);

    // Debounce URL value with 500ms delay
    const debounced = useDebounceCallback(setUrlValue, 500);

    // ... the rest of SiteForm

}

Trigger Validation with useEffect

We can now implement the debounced value in our validation schema. In order to do this, we'll need to make a few updates to the code:

  • Update handleChange() so that it continues to update both field values immediately on each change, but also passes the url value to useDebounceCallback()

  • Prevent url validation when name is being updated

  • Trigger url validation each time the debounced url value is updated

Here are the updates:

// @/components/site-form.tsx

/*
imports,
regex,
form schema,
*/

export const SiteForm = () => {

    // ...

    // Extract additional props from useForm()
    const {
        setValue,
        trigger,
        handleSubmit,
        clearErrors,
        unregister,
        control,
    } = form;

    // ...

    const handleChange = (
        e: React.ChangeEvent<HTMLInputElement>,
        fieldName: keyof z.infer<typeof FormSchema>
    ) => {
        // Extract the value from the target element
        const { value } = e.target;
        // Update the rendered value immediately, 
        // but don't trigger validation yet
        if (fieldName === "url") {
            setValue(fieldName, value, { shouldDirty: true, shouldValidate: false });
            setIsTyping(true);
            debounced(value);
            console.log("URL value: ", value, "Debounced URL value: ", urlValue);
        }
        if (fieldName === "name") {
            // Unregister the URL field to prevent validation
            // while the user is typing the site name
            unregister("url")
            setValue(fieldName, value, { shouldDirty: true, shouldValidate: true });
        }
    };

    // When the debounced urlValue changes, trigger validation
    useEffect(() => {
        // Don't trigger validation if the field is empty
        // Instead, clear any existing field errors
        urlValue ? trigger("url") : clearErrors("url");
        // When the urlValue changes, we know the user has stopped typing
        // due to debounce delay. Update state accordingly.
        setIsTyping(false);
    }, [urlValue])

    // ...

}

After adding these updates, save the file, then run npm run dev in your terminal (make sure you're in your project directory), and open localhost:3000/get-started in your browser.

You should now have a form that debounces the url input value and tells you if it's not a WordPress site!

๐Ÿ’ก
Check your browser console for the logs we added in the handleChange function. You should see that the url value is updated after each keystroke, but the debounced urlValue is only updated once every 500ms.

Bonus: Dynamic UI

If you've made it this far, great job! The form looks great and the core functionality we set out to accomplish is all in place.

One thing the form is lacking is some sort of UI component that keeps the user informed about where they are at in the validation process. By dynamically rendering icons within the url input field, we can make the UI much more user-friendly โ€” especially if we render a success component when the url passes validation.

Install Dependency

We're going to be using my go-to icon library for this UI, Lucide, but feel free to substitute with any other library of your choice. The process should be the same, regardless of what icon library is being used.

Install the Lucide package with the following command:

npm install lucide-react

Add Icon to Input Field

Reading the Field State

After installation is completed, let's import the icons we'll need in @/components/site-form.tsx and also extract a couple more return props from the useForm hook that we'll need for the next steps. I'll explain these props and include links to each one's documentation below.

// @/components/site-form.tsx

// ...imports
import { 
    AppWindow,
    CheckCircle, 
    Loader2, 
    XCircle } 
from "lucide-react"

// ...

const {
        getFieldState, // <-- new
        setValue,
        trigger,
        handleSubmit,
        clearErrors,
        unregister,
        control,
        formState, // <-- new
        formState: {
            isValidating, // <-- new
        }
    } = form;

// ...

Why are we extracting these new props? In the current state of our code, we can determine whether the form inputs are valid or invalid and whether the user is typing, but we're missing a couple of possible field states that will be useful when rendering the dynamic UI.

  • getFieldState: This method allows us to get individual field states and returns several props, including isDirty (the current value is different than the default value) and invalid (the current value is invalid). (Documentation)

  • isValidating: This return prop is a boolean that is set to true during validation. (Documentation)

Now we can define the variables to make them easier to use in our render. I'm going to place them directly above the return statement.

// @/components/site-form.tsx

// ...

const urlIsDirty = getFieldState("url", formState).isDirty;
const urlInvalid = getFieldState("url", formState).invalid;

// return ( ...

Conditional Rendering with TSX

The last step involves updating the url input field that we render in our return statement with some conditional statements:

  • If the field isnot dirty AND the user isnot typing, render the AppWindow icon.

  • If the field is validating OR the user is typing, render the Loader2 icon with spinning animation.

  • If the field is not validating AND is dirty AND is invalid AND the user is not typing, render the XCircle icon and make it red.

  • If the field is not validating AND is dirty AND is not invalid AND the user is not typing, render the CheckCircle icon and make it green.

Replace the existing FormControl for the url field with the following code:

// @/components/site-form.tsx

// ...

<FormControl>
    <div className="relative flex items-center">
        { ( !urlIsDirty && !isTyping ) && 
            <AppWindow size={16} className="absolute left-2" />
        }
        { ( isValidating || isTyping ) && 
            <Loader2 size={16} className="absolute left-2 animate-spin" />
        }
        { ( !isValidating && urlIsDirty && urlInvalid && !isTyping ) && 
            <XCircle size={16} className="absolute left-2 text-red-500"/>
        }
        { ( !isValidating && urlIsDirty && !urlInvalid && !isTyping ) && 
            <CheckCircle size={16} className="absolute left-2 text-green-500" />
        }
        <Input
            {...field}
            onChange={(e) => handleChange(e, field.name)}
            className="pl-8"
        />
    </div>
</FormControl>

// ...

Save the file, run npm run dev, and then go to localhost:3000/get-started in your browser. You should now see an icon in the url input field that changes based on the field state!

Final Code

// @/components/site-form.tsx

"use client"

import { useEffect, useState } from "react";

import { zodResolver } from "@hookform/resolvers/zod";
import { useDebounceCallback } from "usehooks-ts";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { checkUrl } from "@/lib/wordpress";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { 
    Form, 
    FormControl, 
    FormField, 
    FormItem, 
    FormLabel, 
    FormMessage 
} from "@/components/ui/form";
import { 
    Card, 
    CardContent, 
    CardDescription, 
    CardFooter, 
    CardHeader, 
    CardTitle 
} from "@/components/ui/card";

import { 
    AppWindow, 
    CheckCircle, 
    Loader2, 
    XCircle 
} from "lucide-react"

const httpRegex = /^(http|https):/
const completeUrlRegex = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_+.~#?&/=]*)$/

const FormSchema = z.object({
    name: z
        .string()
        .min(1, {
            message: "Please enter a name for your site."
        })
        .max(255, {
            message: "Name must be less than 255 characters."
        }),
    url: z
        .string()
        .min(1, {
            message: "Please enter a URL for your site."
        })
        .max(255, {
            message: "URL must be less than 255 characters."
        })
        .transform((val, ctx) => {
            let completeUrl = val;
            // Prepend https:// if the URL 
            // doesn't start with http:// or https:// 
            if (!httpRegex.test(completeUrl)) {
                completeUrl = `https://${completeUrl}`;
            }
            // If the URL is still invalid, display an error message
            // and pass the fatal flag to abort the validation process early
            // This prevents unnecessary requests to the server to check
            // if the URL is a WordPress site
            if (!completeUrlRegex.test(completeUrl)) {
                ctx.addIssue({
                    code: z.ZodIssueCode.custom,
                    fatal: true,
                    message: "Please enter a valid URL",
                });
                return z.NEVER;
            }
            return completeUrl;
        })
        // This refinement checks if the URL is a WordPress site
        // It only runs if the URL is valid
        .refine(async (completeUrl) => 
            completeUrl && await checkUrl(completeUrl), { 
                message: "Uh oh! That doesn't look like a WordPress site.",
        })
});

export const SiteForm = () => {

    const [urlValue, setUrlValue] = useState<string>("");
    const [isTyping, setIsTyping] = useState<boolean>(false);

    // Debounce URL value with 500ms delay 
    const debounced = useDebounceCallback(setUrlValue, 500);

    const form = useForm<z.infer<typeof FormSchema>>({
        resolver: zodResolver(FormSchema),
        defaultValues: {
            name: "",
            url: "",
        },
        mode: "onChange"
    });

    const {
        getFieldState,
        setValue,
        trigger,
        handleSubmit,
        clearErrors,
        unregister,
        control,
        formState,
        formState: {
            isValidating,
        }
    } = form;

    const handleChange = (
        e: React.ChangeEvent<HTMLInputElement>,
        fieldName: keyof z.infer<typeof FormSchema>
    ) => {
        // Extract the value from the target element
        const { value } = e.target;
        // Update the rendered value immediately, 
        // but don't trigger validation yet
        if (fieldName === "url") {
            setValue(fieldName, value, { shouldDirty: true, shouldValidate: false });
            setIsTyping(true);
            debounced(value);
        }
        if (fieldName === "name") {
            // Unregister the URL field to prevent validation
            // while the user is typing the site name
            unregister("url")
            setValue(fieldName, value, { shouldDirty: true, shouldValidate: true });
        }
    };

    // When the debounced urlValue changes, trigger validation
    useEffect(() => {
        // Don't trigger validation if the field is empty
        // Instead, clear any existing field errors
        urlValue ? trigger("url") : clearErrors("url");
        // When the urlValue changes, we know the user has stopped typing
        // due to debounce delay. Update state accordingly.
        setIsTyping(false);
    }, [urlValue])

    const onSubmit = (values: z.infer<typeof FormSchema>) => {
        console.log(values, "values")
    };

    const urlIsDirty = getFieldState("url", formState).isDirty;
    const urlInvalid = getFieldState("url", formState).invalid;

    return (
        <Form {...form}>
            <form onSubmit={handleSubmit(onSubmit)}>
                <Card className="w-[350px]">
                    <CardHeader>
                        <CardTitle>Create Your Website</CardTitle>
                        <CardDescription>Tell us about your new site to get started.</CardDescription>
                        <CardContent className="py-6 px-0 space-y-4">
                            <FormField 
                                control={control}
                                name="name"
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>Name</FormLabel>
                                        <FormControl>
                                            <Input 
                                                {...field}
                                                onChange={(e) => handleChange(e, field.name)}
                                            />
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />
                            <FormField 
                                control={control}
                                name="url"
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>URL</FormLabel>
                                        <FormControl>
                                            <div className="relative flex items-center">
                                                { ( !urlIsDirty && !isTyping ) && 
                                                    <AppWindow size={16} className="absolute left-2" />
                                                }
                                                { ( isValidating || isTyping ) && 
                                                    <Loader2 size={16} className="absolute left-2 animate-spin" />
                                                }
                                                { ( !isValidating && urlIsDirty && urlInvalid && !isTyping ) && 
                                                    <XCircle size={16} className="absolute left-2 text-red-500"/>
                                                }
                                                { ( !isValidating && urlIsDirty && !urlInvalid && !isTyping ) && 
                                                    <CheckCircle size={16} className="absolute left-2 text-green-500" />
                                                }
                                                <Input
                                                    {...field}
                                                    onChange={(e) => handleChange(e, field.name)}
                                                    className="pl-8"
                                                />
                                            </div>
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />
                        </CardContent>
                        <CardFooter className="justify-end py-0 px-0">
                            <Button>Get Started</Button>
                        </CardFooter>
                    </CardHeader>
                </Card>
            </form>
        </Form>
    )
}

If you made it this far, thanks for sticking with me โ€” I know it was a long read!

In my next article, I'm going to be talking about creating a multi-step form and persisting data to local storage.

See you then!

0
Subscribe to my newsletter

Read articles from Ben Orloff directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ben Orloff
Ben Orloff