Debounce Form Inputs with React Hook Form
Delay Input Validation for Improved Performance and User Experience
Table of contents
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.
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.
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:
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.
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 theurl
value touseDebounceCallback()
Prevent
url
validation whenname
is being updatedTrigger
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!
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, includingisDirty
(the current value is different than the default value) andinvalid
(the current value is invalid). (Documentation)isValidating
: This return prop is a boolean that is set totrue
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 itred
.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 itgreen
.
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!