Cancelling async operations in Vue.js

Michał Męciński
codeburst
Published in
7 min readJan 31, 2018

--

Promises are a very powerful feature in JavaScript, but they lack one important feature: cancellation.

I first started learning asynchronous programming using C#, and I quickly found that the ability to cancel a task, which is an equivalent of a JavaScript promise, was very useful.

Unfortunately, the proposal to include promise cancellation in JavaScript was rejected for several reasons, most importantly because it would cause incompatibility with some existing code. The result is that many existing libraries already invented their own mechanism of cancelling asynchronous operations. For example, axios implements its own cancel token mechanism which is similar to the one used in C#.

In this article I will show you why cancelling an asynchronous operation can be important, even though we often don’t think about it. I will use a simple Vue.js application as an example, but this approach can be used in all kinds of JavaScript code.

Basic example

Imagine that there is a component on the page which has three different tabs. The user can use buttons to switch between them and the component loads some data from the server to display the content of each tab.

Let’s start with this simple version of the component:

<template>
<div>
<button v-on:click="switchTab( 1 )">Tab 1</button>
<button v-on:click="switchTab( 2 )">Tab 2</button>
<button v-on:click="switchTab( 3 )">Tab 3</button>
<div>{{ message }}</div>
</div>
</template>
<script>
export default {
data() {
return {
index: 0,
message: 'Select tab'
}
},
methods: {
switchTab( index ) {
this.index = index;
this.message = 'Loading...';
this.loadMessage( index ).then( message => {
this.message = message;
} );
},
loadMessage( index ) {
return new Promise( ( resolve, reject ) => {
setTimeout( () => resolve( 'Message #' + index ), 1000 );
} );
}
}
}
</script>

The switchTab() method marks the selected tab as active, changes the message to “Loading…” and starts an asynchronous operation which loads the content of the selected tab.

The loadMessage() method uses a timeout to simulate loading the data from the server asynchronously.

I published this code in the following pen so that you can play with it: https://codepen.io/mimec/pen/goJmeR.

If you click on one of the buttons and you will see a ‘Loading…’ message for a while, replaced by the message that emulates the tab content.

Now try click all the three buttons quickly, one after another. Because of the delay, Message #1 and Message #2 will appear for a short while, even though the third tab is already selected. It’s not a perfect solution, but so far it doesn’t seem wrong.

Randomized delay

Now let’s make some change. Instead of hard-coding a 1000 millisecond delay to simulate network latency, let’s make this more realistic by randomizing this delay:

loadMessage( index ) {
const delay = Math.floor( 1000 + Math.random() * 3000 );
return new Promise( ( resolve, reject ) => {
setTimeout( () => resolve( 'Message #' + index ), delay );
} );
},

You can see the result here: https://codepen.io/mimec/pen/opRZRY.

Try repeating the same exercise a few times: click all three buttons quickly. Because each tab takes a different time to load, the results may appear in a different order than expected. For example, the third tab may finish loading before the second tab. When that happens, you can see that Message #2 remains displayed, even though the third tab is active. This is definitely wrong behavior and it can happen in real applications when loading data from the server.

There are a few ways of dealing with this problem. The simplest solution is to check if the same tab is still active when the data is loaded:

switchTab( index ) {
this.index = index;
this.message = 'Loading...';
this.loadMessage( index ).then( message => {
if ( this.index == index )
this.message = message;
} );
}

This will work fine in this simple scenario. But the result of the asynchronous request may depend on multiple conditions, for example a large number of filters. In that case we need a different solution to ensure that the data which was requested last is displayed, and previous requests are discarded.

Cancelling an operation

As I mentioned before, a promise in JavaScript doesn’t have a separate “cancelled” state. A common practice is to reject a promise with a specific type of error. This is usually done like this:

this.loadMessage( index ).then( message => {
this.message = message;
} ).catch( error => {
if ( !( error instanceof CancelledError ) )
// ... handle the error
} );

As you can see, we have to distinguish the error which results from cancelling an operation from other error conditions, for example network errors. One way of doing that is to implement a special type of error.

But in many simple scenarios, cancelled operations can simply be ignored, because we can assume that another operation is already in progress. In that case, the promise can simply remain in a pending state, when it’s neither resolved or rejected.

Let’s add the following method to our component:

loadLastMessage( index ) {
const promise = this.loadMessage( index );
this.lastPromise = promise;
return new Promise( ( resolve, reject ) => {
promise.then( result => {
if ( promise == this.lastPromise ) {
this.lastPromise = null;
resolve( result );
}
} );
} );
}

This methods calls loadMessage() and creates a new promise, which is resolved when the message is loaded, but only if loadLastMessage() was not called again in the meantime.

In order to do that, we store the promise returned by loadMessage() in a local variable and also in a property called lastPromise. Normally, when the promise is resolved, these two values will be equal, and the promise returned by loadLastMessage() is also resolved.

However, when loadLastMessage() is called again in the meantime, then it will set lastPromise to a different value that will no longer match the value stored in the local variable. In that case, the promise returned by the first call to loadLastMessage() will remain unresolved.

This way, we only have to make a simple change to switchTab() to call loadLastMessage() instead of loadMessage():

switchTab( index ) {
this.index = index;
this.message = 'Loading...';
this.loadLastMessage( index ).then( message => {
this.message = message;
} );
}

Now the message will only be updated if there are no other pending operations. Otherwise, it will just keep showing “Loading…” until the last pending operation completes.

Here’s the modified version: https://codepen.io/mimec/pen/vdExOR.

If you quickly click all three buttons, you will notice that now only the last message is displayed. The previous messages never appear. This works correctly even if the timeout for the second button is actually executed after the timeout for the third button, because pressing the third button cancels the second operation.

The loadLastMessage() method can be easily converted to a generic function, which simply takes a promise as a parameter instead of directly calling loadMessage(). This way it can be used to wrap any asynchronous operation that should be cancelled automatically whenever it’s executed again.

There is no error handling in this simple version, but it can also contain an error handler which only calls reject() if the current promise is the last executed one. This way, any errors will be ignored if another operation is pending.

Note that it’s also very easy to cancel a pending operation from the outside, without executing another operation. For example, if the tabs were displayed in a modal dialog, the loading operation could be cancelled when the modal dialog is closed. In order to do that, it’s enough to use the following code:

this.lastPromise = null;

This way, the handler in switchTab() will no longer be called when the final operation completes.

Cancelling a Vuex action

Now let’s imagine that our application uses a Vuex store and loading the data is implemented as an action.

Also, to make this example more realistic, this time we will use an actual call to fetch() instead of emulating it with a timeout.

const state = {
index: 0,
message: 'Select tab',
lastPromise: null
};
const mutations = {
setIndex( state, value ) {
state.index = value;
},
setMessage( state, value ) {
state.message = value;
},
setLastPromise( state, value ) {
state.lastPromise = value;
}
};
const actions = {
loadMessage( { state, commit }, index ) {
commit( 'setIndex', index );
const promise = fetch( '/message/ + 1 );
commit( 'setLastPromise', promise );
promise.then(
result => result.json()
).then( data => {
if ( promise == state.lastPromise ) {
commit( 'setMessage', data.message );
commit( 'setLastPromise', null );
resolve();
}
).catch( error => {
if ( promise == state.lastPromise ) {
commit( 'setLastPromise', null );
reject( error );
}
} );
} );
}
};

Note that the lastPromise is now stored as part of the state of our Vuex store, so that it can be preserved across calls to the loadMessage() action. Besides that, the above code is very similar to the previous example.

The promise which is the result of fetch() is stored in a local variable and also in the store. When fetch() completes, the message is committed to the store and the action is only resolved if there are no other pending operations. Also in case of an error, the action is only rejected if no other actions are being executed.

Depending on your needs, you may prefer a different behavior when an asynchronous operation is cancelled, but this may serve as a starting point for designing your own solution.

--

--