react-final-form icon indicating copy to clipboard operation
react-final-form copied to clipboard

Debouncing Field Validation

Open pmoeller91 opened this issue 6 years ago • 15 comments

Are you submitting a bug report or a feature request?

Feature Request

What is the current behavior?

Currently, there is no way I can find that would allow for debouncing field-level validation. Field-level validation is very nice, but rerunning the validation on every keystroke makes for a poor user experience and lower performance.

Async field-level validation also results in firing off numerous async requests with no trivial way to debounce that, either. Even the linked example for asynchronous validation demonstrates this poor rapid-fire async behavior. (Select username field, select a different field, select username field again. Try typing: "Georgeee" at a moderate pace, and watch as the "username taken" message appears and rapidly disappears as the previous async validation returns and then is replaced by the next async validation)

Right now, even pausing the validation via the form API will result in react-final-form completely stopping all form changes until validation is resumed.

What is the expected behavior?

The ability to debounce validation. All validation should optionally be delayed until a set interval has passed without the form changing. This will reduce the pressure caused by firing numerous async validations, reduce overhead associated with validating even one field on every render, and improve the user experience by delaying error display until typing is completed. Even at 60wpm validation will be triggered on a field as frequently as 5 times every single second.

Other information

There was another issue on this same topic, but the author closed the issue and received no responses.

I have searched pretty far and wide on this issue and have not found any satisfactory answer. Some people have attempted variations of it, but as far as I can tell none have succeeded. It seems like performing field-level validation at absolutely every single render/update is deeply built into final-form itself. There is absolutely no way right now for validation to separated cleanly from the process, and no way for errors to be delivered truly asynchronously using the built-in validation functions.

I could be wrong, and maybe I am! If so, please demonstrate how you can cause a field validation to fire only once when typing stops.

Using OnBlur validation could be one potential solution to help avoid this problem, but it seems more like a bandaid than a true fix.

pmoeller91 avatar Nov 06 '18 22:11 pmoeller91

You can use lodash/debounce function, something like (untested code):

import {Field} from "react-final-form";
import debounce from "lodash/debounce"
class DebouncingValidatingField extends React.Component{
   validate = debounce(this.props.validate,500)
   render(){
      return <Field {...this.props} validate={this.validate}/>
    }
}

tkvw avatar Nov 16 '18 11:11 tkvw

@tkvw A variation of that was among the very first things I tried. Passing a debounced function as the validation function causes validation to fail completely. (That is, it will never be correctly counted as invalid or valid) The validate function is expected to either: Immediately return a promise (which is then collected into an array, every single time validation is run), or immediately return either undefined or an error message. Either way, it requires that something be returned on every single validation pass, and every single validation pass must eventually resolve to either valid or invalid. There is no way to skip validation passes or delay them.

pmoeller91 avatar Nov 17 '18 21:11 pmoeller91

@pmoeller91 : I see you're right: you can try this:

import React from "react";
import PropTypes from "prop-types";
import { Field } from "react-final-form";

class DebouncingValidatingField extends React.Component {
  static propTypes = {
    debounce: PropTypes.number
  };
  static defaultProps = {
    debounce: 500
  };
  validate = (...args) =>
    new Promise(resolve => {
      if (this.clearTimeout) this.clearTimeout();
      const timerId = setTimeout(() => {
        resolve(this.props.validate(...args));
      }, this.props.debounce);
      this.clearTimeout = () => {
        clearTimeout(timerId);
        resolve();
      };
    });
  render() {
    return <Field {...this.props} validate={this.validate} />;
  }
}

export default DebouncingValidatingField;

@see: https://codesandbox.io/s/mmywp9jl1y

and if you only want to debounce the active field you can use:

import React from "react";
import PropTypes from "prop-types";
import { Field } from "react-final-form";

class DebouncingValidatingField extends React.Component {
  static propTypes = {
    debounce: PropTypes.number,
  };
  static defaultProps = {
    debounce: 500,
  };
  validate = (value, values, fieldState) => {
    if (fieldState.active) {
      return new Promise(resolve => {
        if (this.clearTimeout) this.clearTimeout();
        const timerId = setTimeout(() => {
          resolve(this.props.validate(value, values, fieldState));
        }, this.props.debounce);
        this.clearTimeout = () => {
          clearTimeout(timerId);
          resolve();
        };
      });
    } else {
      return this.props.validate(value, values, fieldState);
    }
  };
  render() {
    return <Field {...this.props} validate={this.validate} />;
  }
}

export default DebouncingValidatingField;

tkvw avatar Nov 19 '18 09:11 tkvw

@tkvw Looking at the codesandbox, at first I thought it wasn't quite working correctly...And it wasn't, but it was because of the timeout in the pseudo-async username validation code causing some slight strangeness. Removing the timeout in their username validation code made it work exactly as intended.

I would say this is absolutely a valid solution to the problem, and definitely the first solution I have seen that actually works correctly. Very well done! I am impressed.

I still feel like this is a feature worth incorporating directly into react final form. If not, at the very least, what you have created there may be worth releasing as a lightweight add-on to react-final-form. I could absolutely see myself pulling it in on projects; it absolutely can improve UX, and makes truly async field-level validation viable.

I will leave this open in the time being in the hopes of at least one of those two things happening. Thank you for your responses!

pmoeller91 avatar Nov 20 '18 02:11 pmoeller91

Thank you @tkvw, that is good solution!

But be aware from using Form "validating" flag on your debounced input, if you want to set eg. input disabled={true} while is's validating. You must provide own "validating" prop from DebouncingValidatingField. Don't use "validating" prop that Form provide, because it will trigger to true when your component "debouncing", so you will lose input focus when type one letter. 💪

l3v1k avatar Dec 11 '18 08:12 l3v1k

@l3v1k Please can you explain a bit more what you're referring to? I'm have that problem but not sure how to resolve it. Thanks!

muyiwaoyeniyi avatar Feb 03 '19 02:02 muyiwaoyeniyi

If you don't want to implement the promise debounce logic yourself, you might want to checkout some utility library like this one: https://github.com/sindresorhus/p-debounce

xat avatar Jun 19 '19 09:06 xat

I am still struggling to achieve this debounce behavior on gloabl form level validation. As my form is huge and i don't want to implement per field validations( I am using yup to define schema and validate against it in Form's validate). I guess lodash's debounce does not play well with async/await or I am doing something wrong(probably)

ziaulrehman40 avatar Nov 22 '19 06:11 ziaulrehman40

We also have the case as @ziaulrehman40.

Async validation examples seem to overlook this common case: when you have a form and need to validate async only one field. Not only in Final-forms, but also in Formik, React-hook-form etc.

I think, Yup is a good declarative way to validate a form. So we prefer to use it, instead of duplicating validation rules for single fields. But when there's an async validation it breaks, because Yup can't pass loading state to form's library. On the other hand, separating one field for async validation from Yup, seems pushing you to not use Yup at all.

So for now, fixing this common case on my own, the best (but not great) way I see, is to have validation function which tracks async field changes, validates it, then runs common validation with Yup, and then needs to merge these results and return in one object.

But this still requires a bunch of state tracking happening around the React components.

I'd rather prefer to finally have a common solution to this use case.

artegen avatar Feb 11 '20 12:02 artegen

Thanks @artegen for your thoughts, I found the same issues and I'm now working on something related to your solution. Did you found any problem or can you share a snippet of your solution?

andtos90 avatar May 19 '20 09:05 andtos90

Same scenario here as @ziaulrehman40 @artegen and @andtos90. Any suggestions?

bostrom avatar Oct 09 '20 12:10 bostrom

@bostrom this is my solution (you will need memoizee):

import memoize from 'memoizee'

const createFormValidation = ({
  id,
  message,
  validationFn,
  async = false,
}: createFormValidationArgs) => {
  const memoized = memoize(validationFn, { length: 1 })
  const validationFnWrapper = async function(value) {
    if (!async) {
      return await validationFn.call(this, value)
    }
    return await memoized.call(this, value)
  }
  return [id, message, validationFnWrapper]
}

Create your validation:

 const validateUserName =  createFormValidation({
    id: 'checkUniqueUserName',
    message: "This user name is not valid",
    async: true,
    validationFn: async (value) =>  {
      const isValid = await validateNameAsynchronosly(value)
      return isValid
    },
  })

Usage:

name:  yup.string().test(...validateUserName)

luisgrases avatar Oct 09 '20 16:10 luisgrases

Interesting. When we have many fields with errors and we write to one of them, at the moment of checking the error text is removed from all fields. why is that?

Example

alx2das avatar Mar 18 '21 16:03 alx2das

Here's a memoized debounced version:

export default function DebouncedMemoizedField({
  milliseconds = 400,
  validate,
  ...props
}) {
  const timeout = useRef(null);
  const lastValue = useRef(null);
  const lastResult = useRef(null);

  const validateField = (value, values, meta) => new Promise((resolve) => {
    if (timeout.current) {
      timeout.current();
    }

    if (value !== lastValue.current) {
      const timerId = setTimeout(() => {
        lastValue.current = value;
        lastResult.current = validate(value, values, meta);
        resolve(lastResult.current);
      }, milliseconds);

      timeout.current = () => {
        clearTimeout(timerId);
        resolve(true);
      };
    } else {
      resolve(lastResult.current);
    }
  });

  return <Field validate={validateField} {...props} />;
}

Usage:

<MemoizedDebouncedValidationField
  name="username"
  validate={(value) => (value === 'jim' ? 'Username exists' : undefined)}
  render={({ input, meta }) => (
    <>
      <input {...input} />
      {(meta.touched && meta.error) && <p>Error</p>}
    </>
  )}
/>

onosendi avatar Jan 10 '22 18:01 onosendi

It is 4 in the morning. I don't know why it works and don't care at this point. Here is a debounced validation function for the whole form:

const timeout = useRef<(() => void) | null>(null);

const validateForm = (values: any) =>
    new Promise((resolve) => {
        if (timeout.current) {
            timeout.current();
        }

        const timerId = setTimeout(() => {
            resolve(YOUR_VALIDATE_FUNC(values));
        }, DEBOUNCE_INTERVAL);

        timeout.current = () => {
            clearTimeout(timerId);
            resolve(true);
        };
    });

Later just pass it to form like

<Form validate={validate form} />

Thanks to @onosendi

Radomir-Drukh avatar Aug 25 '23 00:08 Radomir-Drukh