In Ur Solutions have we recently started using react-hook-form on a number of new websites and web apps. It's a modern library that connects the input values that reside on the DOM, instead of the idiomatic “controlled input” 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 { register } from 'react-hook-form'
// ...
const { register, handleSubmit } = useForm<MySchema>({
defaultvalues: {
valueOne: "one",
valueTwo: 2,
}
})
const onSubmit = (data: MySchema) => {
callMyApi(form)
}
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 { register } from 'react-hook-form'
// ...
const { register, handleSubmit } = useForm<MySchema>({
defaultvalues: {
valueOne: "one",
valueTwo: 2,
}
})
const onSubmit = (data: MySchema) => {
callMyApi(form)
}
return (
<form onSubmit={handlesubmit(onSubmit)} >
<input {...register("valueOne", { required: true })}/>
<input {...register("valueTwo", { pattern: /^Ur/i })} />
</form>
)
One challenge I just experienced was that I had a form in need of continuous data storage as the form was updated by the user. In idiomatic controlled-component-react, I'd 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>
)
Hooke UseDebounce
acts as a “throttle”, giving you at most one new value per, in this case, 1000 ms. You can get the hook for example from https://github.com/streamich/react-use. This is necessary so that we do not call our API every time, for example, a new letter is typed. We get an effect where we batch updates and send them off, for example, once a second.
The challenge with this methodology is that we do not have direct access to the form data when using react-hook form. Fortunately, they have a way to listen for new data via the attribute watch.
It can be used in two ways. Either to return the value to the form just 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 gives us the ability to achieve continuous storage 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
not conducting form validation. With react-hook-form, we've probably offloaded validation to the HTML, or via a form validation tool like Zod.
One way to fix this might 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:
- It constantly sends updates.
- It conducts form validation.
- The batcher/throttler updates.
UsedeBounceFN
is a callback variant of UseDebounce
. There are several possible implementations of this. Here's 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 about React-Hook Shape on their websites.