Home > front end >  how to make a React-Query with React-Hook-Form Autosave
how to make a React-Query with React-Hook-Form Autosave

Time:09-06

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;
  • Related