Reacting to Promises from event listeners in Vue.js
Building a button component, that changes automatically on Promise resolution
Users should know the outcome of an action they performed. A common UX pattern is buttons with loading, success and error states.
Changing those states on each action, be it easy with Vue, can be tedious, with allot of repeating code. You have to assign classes, change button text, revert the state and so on.
In this post I will show you how to leverage Promises and event handlers, to automatically change a button’s state. For styling I am using Bulma, but you can easily fit this to work with your custom styles.
This post was inspired from a recent fix in the release of Vue 2.6.
Usage of the component is straight forward
TLDR: Click handlers that return a promise, will be assigned using
$listeners property and resolved inside the button component, changing its state based on the promise resolution. Button exposes slots for further customisation of states.
- Click handlers must return a promise in order for the button to react on it.
- Button calls the click handler from an internal method.
- Outer components must not know how the button handles promise states.
- Button should allow for customisation of each state
With those points laid out, lets build the button.
Returning the promise
First things first, returning the promise. Its a good practice to always return promises from functions, that way you can await them, if called from another function.
The above code is self explanatory, on each call of the
onClick method, the promise from the async request is returned. If you want to catch potential errors and act accordingly, for the button to work, you need to re-throw the error again. Forgetting to do so will cause the button to show a successful state. This is not connected to building the button it self, rather how one could use it.
Using async/await syntax means you don’t need to return a promise, it will await accordingly. This can be seen in the
Calling the handler from the button
To listen for clicks on the component, a normal click event handler is added to the button. The more complex part is passing all event listeners to the button, while overriding the click listener.
To do this, we use the handy
$listeners property on the instance. It gives us reference to all the event listeners bound to the component, in an easy to use object syntax. Link to docs
<button v-on="listeners" ...
From the script above, we see that we use
v-on to bind
listeners, which is a computed property that passes all the parent event bindings, by spreading the object into a new one, overriding the click handler with a custom
handleClick method on the component.
This way we can not only handle clicks, but also hover and other events. Those however, will not track promise resolution, as I think its not a common use case to do so.
Handling the state
handleClick method is an async function, that warps everything in a try/catch block, setting an internal
isLoading property and invoking the passed click event handler via
await this.$listeners.click() . We are essentially accessing the click property from the
$listeners object, which is the click handler passed from the parent. Remember that we are not passing it directly, as we overrode it inside the
listeners computed property.
Making the method async is a handy, as that allows to await the click handler, even if it does not return a promise. To learn more about async/await check this article.
After the promise is resolved, we call a small helper method called
resetDelayed which sets a property called
isSuccess to true, reverting it back to false after X amount of time. This way we can return the button it its initial state while giving the user enough time to notice the change. In the catch block, we use the same method to set the
finally block we just set the
isLoading to false, indicating the end of the loading state.
resetDelayed method sets a timeout of 2s, via the
time prop, after which it returns the passed property to false. One thing to mind here, is that it checks if a
loading prop is passed to the component via:
That allows for manual override of the loading state on the button from the parent component. In such case, we probably don’t care how the promise resolves, so we skip setting
Customising the button states and behavior
To provide some customisation, we can use slots to give the user a way to pass custom icons or text for each of the possible states. We also provide the
loading prop to set the loading state manually if needed. We can even override the amount of time the button stays in success of failure state via the
computedClasses is a computed property, applying styling classes based on the current state the button is in. The
loadingState is just a helper computed property, returning the the
loading prop if passed, otherwise returns the internal