'How to debounce async formik/yup validation, that it will validate when user will stop entering data?

I want to validate user input asynchronously. For example, to check if email already exists, and perform validation while the user typing. To decrease API calls I'd like to debounce API calls with lodash or custom debounce function and to perform validation when the user stops typing.

So far this is my form right now. The issue is that it doesn't work as intended. It looks that denounced function returns a value from the previous call, and I can't understand where is the problem.

You can see a live example here: https://codesandbox.io/s/still-wave-qwww6

import { isEmailExists } from "./api";

const debouncedApi = _.debounce(isEmailExists, 300, {
  trailing: true
});

export default function App() {
  const validationSchema = yup.object({
    email: yup
      .string()
      .required()
      .email()
      .test("unique_email", "Email must be unique", async (email, values) => {
        const response = await debouncedApi(email);
        console.log(response);
        return response;
      })
  });

  const formik = useFormik({
    initialValues: {
      email: ""
    },
    validateOnMount: true,
    validationSchema: validationSchema,
    onSubmit: async (values, actions) => {}
  });

  return (
    <form onSubmit={formik.handleSubmit}>
      <label>
        Email:
        <input
          type="text"
          name="email"
          onChange={formik.handleChange}
          onBlur={formik.handleBlur}
          value={formik.values.email}
        />
        <div className="error-message">{formik.errors.email}</div>
      </label>
    </form>
  );
}

I emulate API call by using following function:

export const isEmailExists = async email => {
    return new Promise(resolve => {
        console.log('api call', email);
        setTimeout(() => {
            if (email !== '[email protected]') {
                return resolve(true);
            } else {
                return resolve(false);
            }
        }, 200);
    })
}

UPDATE: Tried to write my own implementation of debounce function. In such a way, that last Promise' resolve will be kept till timeout expired, and only then function will be invoked and Promise will be resolved.

const debounce = func => {
    let timeout;
    let previouseResolve;
    return function(query) {
         return new Promise(async resolve => {

            //invoke resolve from previous call and keep current resolve
            if (previouseResolve) {
                const response = await func.apply(null, [query]);
                previouseResolve(response);
            }
            previouseResolve = resolve;

            //extending timeout
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            timeout = setTimeout(async () => {
                const response = await func.apply(null, [query]);
                console.log('timeout expired', response);
                previouseResolve(response);
                timeout = null;
            }, 200);
        })
    }
}

const debouncedApi = debounce(isEmailExists);

 const validationSchema = yup.object({
        email: yup
            .string()
            .required()
            .email()
            .test('unique_email', 'Email must be unique', async (email, values) => {
                const response = await debouncedApi(email);
                console.log('test response', response);
                return response;
            })
    });

Unfortunately, it doesn't work either. It looks like yup abort unresolved function calls when the next call happens. When I type fast it doesn't work, when I type slow it works. You can see updated example here: https://codesandbox.io/s/suspicious-chaum-0psyp



Solution 1:[1]

It looks that denounced function returns a value from the previous call

This is how lodash debounce is supposed to work:

Subsequent calls to the debounced function return the result of the last func invocation.

SEE: https://lodash.com/docs/4.17.15#debounce

You could set validateOnChange to false and then call formik.validateForm manually as a side effect:

import debounce from 'lodash/debounce';
import { isEmailExists } from "./api";

const validationSchema = yup.object({
  email: yup
    .string()
    .required()
    .email()
    .test("unique_email", "Email must be unique", async (email, values) => {
      const response = await isEmailExists(email);
      console.log(response);
      return response;
    })
});

export default function App() {
  const formik = useFormik({
    initialValues: {
      email: ""
    },
    validateOnMount: true,
    validationSchema: validationSchema,
    validateOnChange: false, // <--
    onSubmit: async (values, actions) => {}
  });

  const debouncedValidate = useMemo(
    () => debounce(formik.validateForm, 500),
    [formik.validateForm],
  );

  useEffect(
    () => {
      console.log('calling deboucedValidate');
      debouncedValidate(formik.values);
    },
    [formik.values, debouncedValidate],
  );

  return (
    ...
  );
}

This way, the whole validation will be debounced instead of just the remote call.

And it is better to put schema outside of Component if there is no dependencies, it is often slow to do it in every render.

Solution 2:[2]

If you want to use < Formik > component (as me), you can debounce validation like this (thanks for previous answer, it helps me do this):

import { Formik, Form, Field } from "formik"
import * as Yup from 'yup';
import { useRef, useEffect, useMemo } from 'react'
import debounce from 'lodash.debounce'


const SignupSchema = Yup.object().shape({
    courseTitle: Yup.string().min(3, 'Too Short!').max(200, 'Too Long!').required('Required'),
    courseDesc: Yup.string().min(3, 'Too Short!').required('Required'),
    address: Yup.string().min(3, 'Too Short!').max(200, 'Too Long!').required('Required'),
});

export default function App() {
    const formik = useRef() //  <------
    const debouncedValidate = useMemo(
        () => debounce(() => formik.current?.validateForm, 500),
        [formik],
    );

    useEffect(() => {
        console.log('calling deboucedValidate');
        debouncedValidate(formik.current?.values);
    }, [formik.current?.values, debouncedValidate]);

    return (
      <Formik
        innerRef={formik} //  <------
        initialValues={{
            courseTitle: '',
            courseDesc: '',
            address: '',
        }}
        validationSchema={SignupSchema}
        validateOnMount={true} //  <------
        validateOnChange={false} //  <------
        ...

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 seanplwong
Solution 2