I am trying to make a form with React-Hook-Form and React-Query that autosaves whenever the user changes any of the fields (debounced). I am getting close, but it creates an infinite loop when I mutate. Here is what I have:
"@tanstack/react-query": "^4.2.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.34.2",
import React from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as Yup from 'yup'
import debounce from 'just-debounce-it'
type NameType = {
id: number
firstName: string
lastName: string
}
const schemaValidation = Yup.object().shape({
id: Yup.number().required('Required'),
firstName: Yup.string().required('Required'),
lastName: Yup.string()
.min(2, 'Must be greater than 1 character')
.max(50, 'Must be less than 50 characters')
})
const getMockData = async () => {
const name: NameType = {
id: 1,
firstName: 'John',
lastName: 'Doe'
}
return await Promise.resolve(name)
}
const saveChangeToDatabase = async (args: NameType) => {
console.count('payload for patch:' JSON.stringify(args))
return await Promise.resolve(args)
}
const NameForm = () => {
const queryResult = useQuery(['user'], getMockData)
const mutationResult = useMutation(saveChangeToDatabase, {
onSuccess: (nameToSave: NameType) => {
console.count('success mutating: ' JSON.stringify(nameToSave))
}
})
const {
register,
reset,
watch,
formState: { isValid, isDirty, errors }
} = useForm<NameType>({
mode: 'all',
criteriaMode: 'all',
resolver: yupResolver(schemaValidation)
})
const fieldData = watch()
const handleDebouncedChange = debounce((data: NameType) => {
mutationResult.mutateAsync(data)
}, 500)
React.useEffect(() => {
reset(queryResult.data)
}, [queryResult.data])
React.useEffect(() => {
if (isValid && isDirty) {
handleDebouncedChange(fieldData)
}
}, [fieldData, isValid, isDirty])
if (queryResult.isLoading) {
return <h2>Loading...</h2>
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
margin: 'auto',
width: 300
}}>
<input {...register('firstName')} placeholder='First name' />
<div style={{ color: 'red' }}>{errors && errors?.firstName?.message}</div>
<input {...register('lastName')} placeholder='Last name' />
<div style={{ color: 'red' }}>{errors && errors?.lastName?.message}</div>
{'Field data: ' JSON.stringify(fieldData)}
</div>
)
}
export default NameForm
I also made a create-react-app reproduction of this here. You can clone the repo, run npm i , npm start, and you will see the problem when you change the form. This is the only page you need to look at:
https://github.com/k-38/react-query_react-hook-form_autosave/blob/main/src/NameForm.tsx
Any help is appreciated, thank you
UPDATE: thanks so much for the answer. I took your answer and abstracted away a lot of the debounce/callback logic in a custom hook (not typed yet):
import React from 'react'
import debounce from 'just-debounce-it'
export function useDebouncedAutoSave({
mutationResult,
validationSchema,
getValues,
reset,
queryResult
}: {
mutationResult: any
validationSchema: any
getValues: any
reset: any
queryResult: any
}) {
React.useEffect(() => {
reset(queryResult.data)
}, [queryResult.data])
const handleDebouncedChange = React.useMemo(
() =>
debounce((data: any) => {
mutationResult.mutateAsync(data)
}, 500),
[mutationResult.mutateAsync]
)
const onChange = async () => {
try {
const values = getValues()
const validated = await validationSchema.validate(values)
if (!validated) return
handleDebouncedChange(validated)
} catch (e) {}
}
return {
onChange
}
}
CodePudding user response:
So, the infinite loop in functional component with useEffect
is often due to dependencies values mutated on every "loop cycle".
In the documentation we can read:
watch result is optimised for render phase instead of useEffect's deps, to detect value update you may want to use an external custom hook for value comparison.
I suspect (didn't had time to look at the code) that watch
return value is created on every render. Then fieldData
on every render is a reference to a new object.
Most of the time, I rely on onChange
or onBlur
form events.
function Form() {
const onChangeHandler = () => { /* ... */ };
return <form onChange={onChangeHandler}>
{/* ... */}
</form>
}
And then I use useForm().getValues
function to retrieve the current form values, but you need to use you schema validation to trigger the "autosave" function when its valid.
Another solution (maybe a simpler solution): would be to use a custom hook that compare the values deeply. You can take a look at useDeepCompareEffect
from react-use for this.
There is another bug in your code with the debounce function: using
const debouncedFunction = debounce(myFunction, 500)
will not work. A "debounced" function is memoized. As we are in a render function (functional component), the memoized function will be created on every render, so it will be called without respect to the threshold that you set.
You need to use React.useMemo
for this:
const { mutateAsync } = mutationResult;
const handleDebouncedChange = React.useMemo(
() =>
debounce((data: NameType) => {
mutateAsync(data);
}, 500),
[mutateAsync]
);
A fully working version available here as codesandbox would be:
import React from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as Yup from "yup";
import debounce from "just-debounce-it";
type NameType = {
id: number;
firstName: string;
lastName: string;
};
const schemaValidation = Yup.object().shape({
id: Yup.number().required("Required"),
firstName: Yup.string().required("Required"),
lastName: Yup.string()
.required()
.min(2, "Must be greater than 1 character")
.max(50, "Must be less than 30 characters")
});
const getMockData = async () => {
const name: NameType = {
id: 1,
firstName: "John",
lastName: "Doe"
};
return await Promise.resolve(name);
};
const saveChangeToDatabase = async (args: NameType) => {
console.count("payload for patch:" JSON.stringify(args));
return await Promise.resolve(args);
};
const NameForm = () => {
const queryResult = useQuery(["user"], getMockData);
const mutationResult = useMutation(saveChangeToDatabase, {
onSuccess: (nameToSave: NameType) => {
console.count("success mutating: " JSON.stringify(nameToSave));
}
});
const { register, reset, watch, getValues, formState } = useForm<NameType>({
mode: "all",
criteriaMode: "all",
resolver: yupResolver(schemaValidation)
});
const { errors } = formState;
const fieldData = watch();
const { mutateAsync } = mutationResult;
const handleDebouncedChange = React.useMemo(
() =>
debounce((data: NameType) => {
mutateAsync(data);
}, 500),
[mutateAsync]
);
React.useEffect(() => {
reset(queryResult.data);
}, [queryResult.data]);
const onChange = async () => {
const data = getValues();
try {
console.log(formState);
const validated = await schemaValidation.validate(data);
handleDebouncedChange(validated);
} catch (e) {}
};
if (queryResult.isLoading) {
return <h2>Loading...</h2>;
}
return (
<form
style={{
display: "flex",
flexDirection: "column",
margin: "auto",
width: 300
}}
onChange={onChange}
>
<input {...register("firstName")} placeholder="First name" />
<div style={{ color: "red" }}>{errors && errors?.firstName?.message}</div>
<input {...register("lastName")} placeholder="Last name" />
<div style={{ color: "red" }}>{errors && errors?.lastName?.message}</div>
{"Field data: " JSON.stringify(fieldData)}
</form>
);
};
export default NameForm;