
At Ur Solutions, we have recently started using React Hook Form on a number of new websites and web apps. It is a modern library that connects to input values in the DOM instead of using the idiomatic "controlled input" pattern in React. Instead of having code that looks something like this...
const [form, setForm] = useState<MySchema>({
valueOne: "one",
valueTwo: 2,
})
const onSubmit = (e: SubmitEvent) => {
e.preventDefault()
validateFormData(form)
callMyApi(form)
}
return (
<form>
<input value={form.valueOne} />
<input value={form.valueTwo} />
</form>
)
... you have code that looks something like this:
import { useForm } from 'react-hook-form'
// ...
const { register, handleSubmit } = useForm<MySchema>({
defaultValues: {
valueOne: "one",
valueTwo: 2,
}
})
const onSubmit = (data: MySchema) => {
callMyApi(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("valueOne")} />
<input {...register("valueTwo")} />
</form>
)
If you need validation, this can be done directly on the inputs (via HTML's standard for validation):
import { useForm } from 'react-hook-form'
// ...
const { register, handleSubmit } = useForm<MySchema>({
defaultValues: {
valueOne: "one",
valueTwo: 2,
}
})
const onSubmit = (data: MySchema) => {
callMyApi(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("valueOne", { required: true })}/>
<input {...register("valueTwo", { pattern: /^Ur/i })} />
</form>
)
A challenge I just experienced was that I had a form with a need for continuous storage of the data as the form was updated by the user. In idiomatic controlled-component-React, I would do something like this:
const [form, setForm] = useState<MySchema>({
valueOne: "one",
valueTwo: 2,
})
const debouncedForm = useDebounce(form, 1000)
useEffect(() => { onSubmit(debouncedForm) }, [debouncedForm])
const onSubmit = () => {
validateFormData(form)
callMyApi(form)
}
return (
<form>
<input value={form.valueOne} />
<input value={form.valueTwo} />
</form>
)
The hook useDebounce acts as a “throttle”, giving you a maximum of one new value per, in this case, 1000 ms. You can get the hook e.g. from https://github.com/streamich/react-use. This is necessary so we don't call our API every time, e.g. a new letter is entered. We get an effect where we batch updates and send them off, for example, once a second.
The challenge with this approach is that we do not have direct access to the form data when using React Hook Form. Fortunately, it provides a way to listen for new data through watch.
It can be used in two ways. Either to return the value to the form exactly when it is called…
const { register, handleSubmit, watch } = useForm<MySchema>({
defaultValues: {
valueOne: "one",
valueTwo: 2,
}
})
const currentValueOfValueOne = watch("valueOne")
const allCurrentValues = watch()
const bothValues = watch(["valueOne", "valueTwo"])
or in as a subscription...
const { register, handleSubmit, watch } = useForm<MySchema>({
defaultValues: {
valueOne: "one",
valueTwo: 2,
}
})
useEffect(() => {
const subscription = watch((newValue, { name, type }) => {
// Do something with the new value
})
}, [watch])
This allows us to implement continuous saving with React Hook Form:
const { register, handleSubmit, watch } = useForm<MySchema>({
defaultValues: {
valueOne: "one",
valueTwo: 2,
}
})
const allCurrentValues = watch()
const debouncedValues = useDebounce(allCurrentValues, 1000)
useEffect(() => {
callMyApi(debouncedValues)
}, [debouncedValues])
Great, so this is what it takes? Not so fast.
The problem here is that watch does not perform form validation. With React Hook Form, validation has probably been delegated to HTML or to a form validation tool such as Zod.
One way to fix this could be to move the validation back to Javascript/Typescript with a validateFormData method. But there is an easier way.
We can use the subscription version of watch and create a new submit event inside the form directly:
const { register, handleSubmit, watch } = useForm<MySchema>({
defaultValues: {
valueOne: "one",
valueTwo: 2,
}
})
const { run } = useDebounceFn(() => {
formRef.current?.dispatchEvent(
new Event('submit', { cancelable: true, bubbles: true })
)
}, 1000)
useEffect(() => {
const subscription = watch(() => run())
return () => {
subscription.unsubscribe()
}
}, [run, watch])
This code does what it's supposed to:
useDebounceFn is a callback variant of useDebounce . There are several possible implementations of this. Here is the one we use:
import { useEffect, useRef } from 'react'
export function useDebounceFn<T extends any[]>(
fn: (...args: T) => void,
wait: number
) {
const fnRef = useRef(fn)
fnRef.current = fn
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const argsRef = useRef<T | null>(null) // Store arguments
const cancelDebouncedFn = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
const debouncedFn: (...args: T) => void = (...args: T) => {
argsRef.current = args // Store arguments when debouncedFn gets called
cancelDebouncedFn()
timeoutRef.current = setTimeout(() => {
if (argsRef.current) {
// Check if args have been stored
fnRef.current(...argsRef.current)
}
}, wait)
}
const flushDebouncedFn = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
if (argsRef.current) {
// Check if args have been stored
fnRef.current(...argsRef.current) // Use stored args
}
}
}
useEffect(() => {
return cancelDebouncedFn
}, [])
return {
run: debouncedFn,
cancel: cancelDebouncedFn,
flush: flushDebouncedFn,
}
}
You can read more on the React Hook Form website.
From MVP prototypes to scalable platforms, our full-stack dev team turns your roadmap into rock-solid code. Get to market faster without sacrificing quality.
Get started