10000 Debouncing Field Validation · Issue #369 · final-form/react-final-form · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Debouncing Field Validation #369

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
pmoeller91 opened this issue Nov 6, 2018 · 16 comments
Open

Debouncing Field Validation #369

pmoeller91 opened this issue Nov 6, 2018 · 16 comments

Comments

@pmoeller91
Copy link
pmoeller91 commented Nov 6, 2018

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.

@tkvw
Copy link
tkvw commented Nov 16, 2018

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}/>
    }
}

@pmoeller91
Copy link
Author
pmoeller91 commented Nov 17, 2018

@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.

@tkvw
Copy link
tkvw commented Nov 19, 2018

@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;

@pmoeller91
Copy link
Author

@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!

@l3v1k
Copy link
l3v1k commented Dec 11, 2018

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. 💪

@muyiwaoyeniyi
< 8000 /path> Copy link

@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!

@xat
Copy link
xat commented Jun 19, 2019

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

@ziaulrehman40
Copy link

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)

@artegen
Copy link
artegen commented Feb 11, 2020
8000

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.

@andtos90
Copy link

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?

@bostrom
Copy link
Contributor
bostrom commented Oct 9, 2020

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

@luisgrases
Copy link
luisgrases commented Oct 9, 2020

@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)

8000
@alx2das
Copy link
alx2das commented Mar 18, 2021

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

@onosendi
Copy link
onosendi commented Jan 10, 2022

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>}
    </>
  )}
/>

@Radomir-Drukh
Copy link
Radomir-Drukh commented Aug 25, 2023

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

0