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

3

10

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

Proterozoic answered 5/11, 2021 at 21:1 Comment(0)
S
6

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.

Succubus answered 10/11, 2021 at 15:42 Comment(2)
Have some question after more close look. Why do you track debouncedValidate in useEffect?Proterozoic
It is because when calling another function created/obtained from the closure, it is better to also make it a dependency so that when they change, the side effect will be performed with the updated deps again to make sure everything is updated. This kind of bugs are harder to track so better avoid them in the first place. eg, schema may depend on some state and then validateForm will be updated, not tracking it means side effect will always be performed with the initial version.Succubus
L
4

Another way to accomplish this if you don't want all validation debounced (only the field's async validation) is to leverage a custom change handler and setFieldError.

If you want to prevent submits while validating you can use setStatus.

Example

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

const debouncedEmailValidation = debounce((val, setFieldError) => {
  isEmailExists(val)
    .then(res => {
      if (res.data.exists) {
        setFieldError('email', 'Email already exists');
      }
    });
}, 300, {
  trailing: true
});

const validationSchema = yup.object({
    email: yup
      .string()
      .required()
      .email()
  });

export default function App() {
  return (
    <Formik
      initialValues={{
        email: ""
      }}
      validationSchema={validationSchema}
      onSubmit={console.log}
    >
      {({ values, errors, handleBlur, handleChange, setFieldError }) => {
        const handleEmailChange = (e) => {
          handleChange(e);
          debouncedEmailValidation(e.target.value, setFieldError);
        };

        return (
          <Form>
            <label>
              Email:
              <input
                type="text"
                name="email"
                onChange={handleEmailChange}
                onBlur={handleBlur}
                value={values.email}
              />
              <div className="error-message">{errors.email}</div>
            </label>
          </Form>
        )
      }}
    </Formik>
  );
}
Lintwhite answered 27/8, 2022 at 14:2 Comment(1)
This was great! One issue though is you need to get the current value of the input, if not your validation will always be a character behind: debouncedEmailValidation(e.target.value, setFieldError);Azikiwe
F
1

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} //  <------
        ...
Fcc answered 29/3, 2022 at 20:9 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.