The callback hell. It's the one thing dreaded more than anything by Javascript developers. Especially when dealing with legacy APIs like jQuery or the Node standard library. Fortunately, solutions were put in place. Frameworks like Angular appeared which eased HTML rendering. Promises brought a standard and easy way to handle asynchronous functions. Now async
/await
makes it easy to write asynchronous code with non-linear paths.
However, now that this layer stabilizes into its final shape, it is a good idea to start wondering how we're going to build higher-level patterns that we can use for UI development.
Any typical UI basically breaks down into two things. A lot of information in addition to inputs to navigate/filter/change this information. All of that happens on the server side, the front-end simply being a view of that. Which means that front-end and API have to communicate quite often to react to user input. If you've been doing that long enough you know that:
- It's not instant, you need to warn the user about the ongoing action
- Users tend to be
stupidimpatient and click the buttons a gazillion times during loading - Sometimes errors occur and you always forget to catch them at some point, usually crashing the whole thing and leaving the UI in an undesirable state
There is of course a lot of other problems but I focus on these because they all are related to one above-mentioned particularity in everyone's favorite language. Wrapping your head around asynchronous code is fucking hard. Wrapping your user's head around it is even harder.
Expected flow
All right then let's not do it. Or rather, do it once and for all and then stick to an easier mental schema.
Suppose that you're doing some instant-search-like UI. You type into an input and the results appear live underneath. Put the edge cases away. What mental model do you make of it?
- A user event triggers a call (
__call__()
) - You check if the request is valid (
validate()
) - Then make sure that a loader is displayed to the user (
prepare()
) - At this point you can run the request (
run()
) - Depending on the outcome you either handle the results (
success()
) or the error (failure()
) - Now that all is loaded you disable the loader (
cleanup()
)
And why would it be more complicated? Keep that model in mind and implement each of the hooks then you're good to go. Thanks to Promises, whatever task that run()
does can be abstracted away like that. Especially since most of the time it's a single API call through axios
or another HTTP library which already returns promises.
Now of course, what happens if the user clicks during the run()
? What if you want to wait before doing the first request? Well I thought about the possible edge cases and came up with this diagram:
Do you need to understand it all? Maybe, maybe not. All the arrows, connections and hooks were thought carefully to be as orthogonal as possible and so it can be pushed further if needs to be. If that's what you want to do then you obviously need to understand it. If not, just follow the instructions, keep the simplified model in mind and all will be fine!
Code example
Of course, I didn't stop at the diagram. Code is all that matters right?
Introducing wasync/debounce!
For the matter of this example we'll walk through some code inspired by the debounce demo.
We're doing a mock search. You type something, it goes into a mock function which echoes the query after 1 second and you display the results. All of that using a Vue component.
The template is pretty simple:
<div class="debounce">
<div>
<input type="text" v-model="search">
</div>
<ul>
<li>Search = {{ search }}</li>
<li>Result = {{ result }}</li>
<li>Loading = {{ loading }}</li>
</ul>
</div>
We rely on a few variables:
-
search
is the search query text -
result
is the result of that query -
loading
is a flag indicating the current loading state
Now let's insert the Debounce into the component:
import {ObjectDebounce} from 'wasync';
export default {
// ...
watch: {
search: new ObjectDebounce().func({
// insert code here
})
},
}
From now on we'll call the output of new ObjectDebounce().func()
the debounced function.
As you can see, the debounced function can directly be used to watch a Vue value (in this case, the search text). Thanks to the Vue watching system, this value will be passed as argument to the search()
function whenever the search
value changes.
validate(search) {
return {search};
},
The arguments used to call the debounced function — in this case the search value — are passed verbatim to the validate()
hook. This hook does two things:
- Validate the input. If the input values are not good, then it needs to return a false-ish value.
-
Generate run parameters. The return value of
validate()
will be passed as argument torun()
. If you are returning an object, make sure that it is a copy that does not risk to mutate in the during the run.
prepare() {
this.loading = true;
},
The prepare()
hook is here to let you prepare the UI for loading. In this case, just set the loading
flag to true
.
cleanup() {
this.loading = false;
},
On the other hand, when the function finishes running we want to disable the loader and we just do that by setting loading
to false
.
run({search}) {
return doTheSearch({search});
},
That is the main dish. It's where we actually do the work. Here it's symbolized by the doTheSearch()
function, but you can do any async work you want to do.
- If
run()
returns aPromise
then it will be awaited. - The first and only parameter of
run()
is the return value ofvalidate()
. - If the debounced function is called while running, only the latest call will result in another
run()
, the other ones will be discarded. - All exceptions and promise rejection will be caught and will trigger the
failure()
hook
success(result) {
this.result = result;
},
The success receives the return/resolve value from run()
as first and only parameter. Then it's up to you to do something with it!
failure(error) {
alert(error.message);
},
Thing's don't always go as planned. If run()
raises an exception or is rejected then the exception will be passed as first and only parameter of failure()
.
Recap
In the end, this is what our component looks like:
<template>
<div class="debounce">
<div>
<input type="text" v-model="search">
</div>
<ul>
<li>Search = {{ search }}</li>
<li>Result = {{ result }}</li>
<li>Loading = {{ loading }}</li>
</ul>
</div>
</template>
<script>
import {ObjectDebounce} from 'wasync';
function doTheSearch({search}) {
return new Promise((resolve) => {
setTimeout(() => resolve(`You searched "${search}"`), 1000);
});
}
export default {
data() {
return {
search: '',
result: '',
loading: false,
};
},
watch: {
search: new ObjectDebounce().func({
validate(search) {
return {search};
},
prepare() {
this.loading = true;
},
cleanup() {
this.loading = false;
},
run({search}) {
return doTheSearch({search});
},
success(result) {
this.result = result;
},
failure(error) {
alert(error.message);
},
})
},
}
</script>
While this looks trivial, it's actually battle-hardened code that will provide a smooth experience to the user no matter what their action is!
Please note that you can test stand-alone Vue components thanks to vue-cli.
Conclusion
Some very common problems linked to asynchronous resources and user interaction can be solved by a pattern that is quite complex but that is fortunately factorized into a generic library within the wasync
package.
This is shown in action within a simple Vue component with pretty straightforward code which is actually quite close to what you would use in production.
It comes from the experience of several projects which was finally factorized into a library. I'm eager to get everybody's feedback on this, other solutions that have been used and if you think you can apply it to your needs!
Top comments (5)
Thanks for the feedback!
If I understand your post correctly, I think it's what the lib actually does under the hood. You can look at the diagram, there is actually 3 entry points (in green) depending on the current state
Calls are not stacked, so only the latest call is taken in account. Since you can't cancel Promises (yet?) though you indeed have to wait for the previous call to finish before running a new one.
Is that clearer? Or am I mistaken?
There are no threading in JS, JS run-time env is single-thread. I guess the library is doing something for request aborting under the hood, which is very common and can be easily done with Axios lib. As for the state machine,
xstate
is one worthy to check it out.I'm really like this article which inspires me plan my state machine with xstate. Thanks!
Indeed, as @leoyli explained, JS has no threads because it follows a completely async paradigm. Which is kinda good but it just took 20 years to get the actual tools to deal with it.
I understand better your vision and actually you're not the first one to tell me this so I might think about it and produce something to solve that need too. However that's outside the scope of what I intended here.
To be specific, I deal a lot with things like "filter forms" where the user chooses filters which instantly impact the displayed results. In that case, only the latest state of the filters matters because you're only displaying the results currently matching the filters. That's why discarding intermediate queries makes sense!
Very interesting and fresh. Thanks a lot!
Excellent! This provides a very elegant way to use the debounce pattern. Also, nice to see the sample in Vue.