First of all, @DA01's answer is 100% correct IMO. I just want to expand on this a bit in the particular case of form elements with asynchronous validations in case it's helpful for you, dear reader. I included some screenshots (apologies in advance for my ugly wireframes).
--
Our team ran into this problem recently while building some complex forms with entangled states (unavoidable; it's a developer tool). We have related form elements using both synchronous (i.e. immediate) and asynchronous (i.e. takes a few hundred milliseconds) validation rules, where changes to one form element can affect the other, and changes to any form element, can cause the client-side validation of the entire form to fail or succeed.
In our case, the validations are usually asynchronous because we're doing static analysis on manually-entered JSON expressions and don't want to block the event loop in the browser-- but the timing (100ms-500ms delay) is more or less equivalent to situations where your form is talking to a fast endpoint on a server (e.g. username or email address availability checks in signup forms).
It's tricky-- on one hand, you don't want to give the user a false positive, but on the other, you don't want to let the user think they can't submit the form.
Here's the best-practices we ended up adopting:
Whenever validation begins to run (typically keypress or blur), go dirty:
- switch the "Submit" button into an interstitial state (this is the
always-active
state accurately suggested by @DA01).
- if the design calls for it, render the form element as "validating" (we call this "dirty"). Many sites (e.g. GitHub's enrollment form) show a loading spinner next to the form element label for this.
- lock any form elements that depend on this one and update any relevant help text (in implementation-land, we accomplished this by triggering a custom dirty event)
^ dirty (save button interstitial)
Note that the button still looks clickable, and only a tiny bit different from the normal enabled "Save" button-- just enough so that power users know what's going on. You could easily just have it look exactly the same if the delay for validation is short enough, or if this is not a form your users are going to be filling out multiple times on a daily basis.
Also notice that dependent form elements are locked/disabled.
Finally, note that if the delay for your validation is longer (maybe >= 500ms) you might opt to display an inline loading spinner next to the form element being validated. GitHub's signup flow does a great job of this if you're looking for inspiration.
When validation completes, go clean:
- Unlock dependent form elements that were locked strictly because this element was dirty (allowing them to be interacted with again)
- switch the "Submit" button into the disabled or enabled state, depending on the validity of this element (which you now know) and render any errors and disable/enable other form elements as appropriate
^ clean (but invalid, save button disabled, dependent form elements errored)
^ clean (valid, save button enabled, dependent form elements enabled)
When the user attempts to submit the form and the "Submit" button is in a normal (enabled) or interstitial state:
- Immediately switch on your syncing state (loading spinner) and lock the "Submit" button to prevent double-submissions
- rerun client-side validations again manually (you could try to be clever here, but I'd suggest always re-running to be safe)
- if everything is ok, finish up. If you're not navigating away from the page, be sure that the not-yet-triggered "clean" event from the unfinished inline validation won't cause problems (i.e. in the callback for your inline validations, before doing anything else, immediately check to see whether the form still even exists, and if not,
return
early)
Note that, for the strategy above to work, you need to debounce (e.g. _.debounce()
from lodash) your validations so that you're sure they run in order. If the validation is asynchronous, you'll also need to use a spinlock, e.g. if (metadata.isValidating) { return; } metadata.isValidating = true; validate(function whenDone(isValid){ metadata.isValidating = false; /* now trigger "clean" event */ });