Coding Better Composables: Async Without Await (5/5)

You can’t live with it, but you can’t live without it. It makes our lives as developers so much easier. Yet, it can also make our lives a waking nightmare. It’s sometimes a blessing, other times a curse. I’m talking about asynchronous code.

If you can get async code to work correctly, it can significantly simplify your code. But wrangling that added complexity, especially with composables, can be confusing.

This article walks through the Async without Await pattern. It’s a way to write async code in composables without the usual headaches. This is the final article in a five-part series on Vue.js composables. If you’ve missed the first four articles in this series, you can start from the beginning. By following the entire series, you’ll understand how to craft better composables by implementing several best practices.

Now let’s get started exploring Async without Await.


Async Without Await

Writing async behavior with the composition API can be tricky at times. All asynchronous code must be at the end of your setup function after any reactive code. If you don’t do this, it can interfere with your reactivity.

The setup function will return when it runs into an await statement. Once it returns, the component is mounted, and the application continues executing as usual. Any reactivity defined after the await, whether it’s a computed prop, a watcher, or something else, won’t have been initialized yet.

This means that a computed property defined after an await won’t be available to the template at first. Instead, it will only exist once that async code is finished and the setup function completes execution.

However, there is a way to write async components that can be used anywhere, without all of this trouble:

const count = ref(0);
// This async data fetch won't interfere with our reactivity
const { state } = useAsyncState(fetchData());
const doubleCount = computed(() => count * 2);

This pattern makes working with async code so much safer and more straightforward. Anything that reduces the amount of stuff you have to keep track of in your head is always helpful!


Implementing the Async Without Await Pattern

To implement the pattern, we’ll hook up all of the reactive values synchronously. Then, those values will be updated asynchronously whenever the async code finishes.

First, we’ll need to get our state ready and return it. We’ll initialize with a value of null because we don’t know what the value is yet:

export default useMyAsyncComposable(promise) {
  const state = ref(null);
  return state;
}

Second, we create a method that will wait for our promise and then set the result to our state ref:

const execute = async () => {
  state.value = await promise;
}

Whenever this promise returns, it will update our state reactively.

Now we just need to add this method into our composable:

export default useMyAsyncComposable(promise) {
  const state = ref(null);

  // Add in the execute method...
  const execute = async () => {
    state.value = await promise;
  }

  // ...and execute it!
  execute();

  return state;
}

We invoke the execute function right before returning from the useMyAsyncComposable method. However, we don’t use the await keyword.

When we stop and wait for the promise inside the execute method, the execution flow returns immediately to the useMyAsyncComposable function. It then continues past the execute() statement and returns from the composable.

Here’s a more detailed illustration of the flow:

export default useMyAsyncComposable(promise) {
  const state = ref(null);

  const execute = async () => {
    // 2. Waiting for the promise to finish
    state.value = await promise

    // 5. Sometime later...
    // Promise has finished, `state` is updated reactively,
    // and we finish this method
  }

  // 1. Run the `execute` method
  execute();
  // 3. The `await` returns control to this point 

  // 4. Return state and continue with the `setup` function
  return state;
}

The promise is executed “in the background,” and because we aren’t waiting for it, it doesn’t interrupt the flow in the setup function. We can place this composable anywhere without interfering with reactivity.

Let’s see how some VueUse composables implement this pattern.


useAsyncState

The useAsyncState composable is a much more polished version of what we’ve already experimented with in this article.

It lets us execute any async method wherever we want, and get the results updated reactively:

const { state, isLoading } = useAsyncState(fetchData());

When looking at the source code, you can see it implements this exact pattern, but with more features and better handling of edge cases.

Here’s a simplified version that shows the outline of what’s going on:

export function useAsyncState(promise, initialState) {
  const state = ref(initialState);
  const isReady = ref(false);
  const isLoading = ref(false);
  const error = ref(undefined);

  async function execute() {
    error.value = undefined;
    isReady.value = false;
    isLoading.value = true;

    try {
      const data = await promise;
      state.value = data;
      isReady.value = true;
    }
    catch (e) {
      error.value = e;
    }

    isLoading.value = false;
  }

  execute();

  return {
    state,
    isReady,
    isLoading,
    error,
  };
}

This composable also returns isReady, which tells us when data has been fetched. We also get the isLoading ref and an error ref to track our loading and error states from the composable.

Now let’s look at another composable, which I think has a fascinating implementation!


useAsyncQueue

This composable is a fun one (there are lots of fun composables in VueUse!).

If you give useAsyncQueue an array of functions that return promises, it will execute each in order. But it does this sequentially, waiting for the previous task to finish before starting the next one. To make it even more useful, it passes the result from one task as the input to the next task:

// This `result` will update as the tasks are executed
const { result } = useAsyncQueue([getFirstPromise, getSecondPromise]);

Here’s an example based on the documentation:

const getFirstPromise = () => {
  // Create our first promise
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(1000);
    }, 10);
  });
};

const getSecondPromise = (result) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(1000 + result);
    }, 20);
  });
};

const { activeIndex, result } = useAsyncQueue([
  getFirstPromise,
  getSecondPromise
]);

Even though it’s executing code asynchronously, we don’t need to use await. Even internally, the composable doesn’t use await. Instead, we’re executing these promises “in the background” and letting the result update reactively.

Let’s see how this composable works. In order to implement the Async Without Await pattern, this composable first hooks up the activeIndex and result values that will be returned:

// Default state values that can be updated reactively
const initialResult = Array.from(new Array(tasks.length), () => ({
  state: promiseState.pending,
  data: null,
});

// Make the reactive version that we'll return
const result = reactive(initialResult);

// Also set up the active index as a ref
const activeIndex = ref(-1);

However, the main functionality is powered by a reduce that works through each function one by one:

tasks.reduce((prev, curr) => {
  return prev.then((prevRes) => {
    if (result[activeIndex.value]?.state === promiseState.rejected && interrupt) {
      onFinished();
      return;
    }

    return curr(prevRes).then((currentRes) => {
      updateResult(promiseState.fulfilled, currentRes);
      activeIndex.value === tasks.length - 1 && onFinished();
      return currentRes;
    })
  }).catch((e) => {
    updateResult(promiseState.rejected, e);
    onError();
    return e;
  })
}, Promise.resolve());

Reduce functions can get a little complicated, so we’ll break it down. First, we start the whole chain with a resolved promise:

tasks.reduce((prev, curr) => {
  // ...
}, Promise.resolve());

Then, we start processing each task. We do this by chaining a .then off of the previous promise. If the promise has been rejected, we may want to just abort early and return:

// Check if our last promise was rejected
if (result[activeIndex.value]?.state === promiseState.rejected && interrupt) {
  onFinished();
  return;
}

If we don’t abort early, we execute the next task, passing in the result from the previous promise. We also call the updateResult method to reactively add to the result array that this composable returns:

// Execute the next task with the result from the previous task
return curr(prevRes).then((currentRes) => {
  updateResult(promiseState.fulfilled, currentRes);
  activeIndex.value === tasks.length - 1 && onFinished();
  return currentRes;
});

As you can see, this composable implements the Async Without Await pattern, but this pattern is only a few lines of the entire composable. So it doesn’t require a lot of extra work, just remembering to put it in place!


Wrapping It Up

We can use async composables much more easily if we use the Async Without Await pattern. This pattern lets us place our async code wherever we want without worrying about breaking reactivity.

The key principle to remember is this: if we first hook up our reactive state, we can update it whenever we want, and the values will flow through the app because of reactivity. So there’s no need to await around!

This article is the end of Vue Mastery’s composable series, and I hope you’ve enjoyed learning how to craft better composables with me! We covered a lot of different topics:

  1. How to use an options object parameter to make your composables more configurable
  2. Using the ref and unref to make our arguments more flexible
  3. A simple way to make your return values more useful
  4. Why starting with the interface makes your composables more robust
  5. How to use async code without the need for await — making your code easier to understand

Download the cheatsheets

Save time and energy with our cheat sheets, that give you all the essential syntax at your fingertips.

  • Vue 3 Composition API
  • Vue Essentials
  • Nuxt
  • Pinia
  • Vue Router
  • Vue 3 Migration Guide