Handling Asynchrony in Vue 3 / Composition API

Part 1: Managing Async state

Martin Malinda
Aug 8 · 6 min read
Image for post
Image for post
Reactive state in vue-concurrency

A couple of months ago at work, we’ve decided to go all-in on Composition API with a new version of our product.

And from what I can tell — looking around at new plugins appearing, all the discussions in the discord community — the composition API gets increasingly more popular. It’s not just us.

Composition API makes a lot of things easier but it also brings some challenges as some things need to be rethought regarding how they fit into this new concept and it might take some time before the best practices are established.

One of the challenges is managing state, especially when asynchrony is involved.

I’ll try to showcase different approaches for this, including vue-concurrency, which I’ve created and that I maintain.

Managing async state

You might ask what’s there to manage? Composition API is flexible enough, this shouldn’t be a problem.

The most typical asynchronous operation is doing an AJAX request to the server. In the past years, some generic issues of AJAX have been solved or mitigated. The callback hell was extinguished with Promises and those were later sugared into async/await. Async/await is pretty awesome and the code is a joy to read compared to the original callback hell spaghetti that was often written years before.

But the fact, that things are much better now doesn’t mean that there’s no more space for improvement.

Async function / Promises don’t track state

When you work with a promise, there’s a promise.then , promise.catch ,promise.finallyand that’s it. You cannot accessstatus or someisPending property and other state properties. That’s why you often have to manage this state yourself and with Composition API, your code might look like this:

Here we pass refs like isLoading error data to the template. getUsers function is passed too to allow retrying the operation in case of an error. You might think the code above might still be quite reasonable and in a lot of cases, I’d agree with you. If the asynchronous logic isn’t too complicated, managing state like this is still quite doable.

Yet I hid one logical mistake in the code above. Can you spot it?

isLoading.value = false; happens only after the successful loading of data but not in case of an error. If the server sends an error response, the view would be stuck in spinner land forever.

A trivial mistake but an easy one to make if you’re repeating logic like this over and over again.

Eliminating boilerplate code in this case also means eliminating the chance of logical mistakes, typos and so on. Let’s look at different ways how to reduce this:

Custom hook: useAsync, usePromise and so on

You might create your own hook, your own use function that would wrap the logic above. Or you might pick a solution from existing composition API utility libs:

vue-use — useAsyncState

const { state, ready } = useAsyncState(
axios
.get('https://jsonplaceholder.typicode.com/todos/1')
.then(t => t.data),
{
id: null,
},
)

Pros: simple, accepting plain promise. Cons: no way to retry.

vue-composition-toolkit useAsyncState

const { refData, refError, refState, runAsync } = useAsyncState(() => axios('https://jsonplaceholder.typicode.com/todos/1'))

Pros: all state is covered. Cons: maybe verbose naming?

<Suspense />

Suspense is a new API, originally coming from React land that tackles this problem in a little bit different, quite a unique way.

If Suspense is about to be used, we can start right away with using async / await directly in the setup function:

But wait, there’s no <Suspense> being used so far! That’s because it would actually be used in a parent component relative to this one. Suspense effectively observes components in its default slot and can display a fallback content if any of the components promises are not fulfilled:

In this case <Suspense> waits for promises to be fulfilled both in <Admins /> and <Users /> . If any promise rejects or if some other error is thrown, it’s captured in the onErrorCaptured hook and set to a ref.

This approach has some benefits over the hooks outlined above, because those work via returning ref and therefore in your setup function you have to take into account the possibility that the refs are not filled with data yet:

setup() {
const { refData: response } = useAsyncState(() => ajax('/users');
const users = computed(() => response.value
&& response.value.data.users);
return { users };
}

With TS chaining operator it might become just response.value?.data.users . But still, with <Suspense /> you don’t need to deal with ref and you don’t even need a computed in this case!

const response = await ajax('/users');
const { users } = response.data;
return { users };

Pros:

  • Plain async / await directly in setup function!
  • no need to use so many ref and computed !

Cons:

  • The logic, by design, has to be split into two (or more) components. Error handling and loading view have to be handled in the parent component.
  • The fact that data loading is done in a child component and loading / error handling is done in a parent component might be counterintuitive at first
  • Error handling needs to be done via some extra boilerplate code of onErrorCaptured and setting a ref manually.
  • Suspense is handy for async rendering of data, but might not be ideal let's say for async handling of saving a form, conditionally disabling buttons and so on. A different approach is needed for that.

vue-promised — <Promised />

There’s another approach via a special component: <Promised /> . It is used in a more classical way — it accepts a promise in a prop rather than observing the state of child components as <Suspense /> does. Setting up the error and loading views is being done via named slots:

Pros:

  • Compared to <Suspsense />: possibility to have all the data / loading / error views in the same place.

Cons:

  • Same as <Suspense />: limited to async rendering, not ideal for other usecases such as submitting a form / toggling state of a button and so on.
  • Compared to <Suspsence />you might need to use more ofref and computed .

vue-concurrency — useTask

vue-concurrency —a plugin that I’ve created because I wanted to experiement with a new approach in Vue — borrows a well-proven solution from ember-concurrency to tackle these issues. The core concept of vue-concurrency is a Task object which encapsulates an asynchronous operation and holds a bunch of derived reactive state:

There’s some more specific syntax here compared to the previous solutions, such as perform yield and isRunning , accessing last and so on. vue-concurrency does require a little bit of initial learning. But it should be well worth it. yield in this case behaves the same as await so that it waits for Promise resolution. perform() calls the underlying generator function.

Pros:

  • The Task is not limited to a template. The reactive state can be used elsewhere.
  • The reactive state on a Task can easily be used for disabling buttons, handling form submissions
  • The Task can always be performed again which allows retrying the operation easily.
  • Task instance is PromiseLike and so it can be used together with other solutions, such as <Promised /> .
  • Tasks scale up well for more complex cases because they offer cancelation and concurrency management — that makes it easy to prevent unwanted behavior and implement techniques like debouncing, throttling, polling.

Cons:

  • Compared to <Suspense /> some extra refs and computed might be needed.
  • A new concept needs to be learned, even if quite minimal.

Conclusion

When we deal with async logic we are most likely using some kind of async functions and we deal with Promises. A state that would track running progress, errors, and resolved data then needs to be handled on the side.

<Suspense /> allows eliminating excessive use of ref and computed and allows usage of async/await directly in setup . vue-concurrency brings a concept of a Task that is well flexible to be used in and out of templates and can scale up for more advanced scenarios.

Up next

In the next article, I’d like to take a deeper look into another drawback of Promises and how to work around it: lack of cancelation. I’ll show how vue-concurrency solves the issue with generator functions and what benefit it brings, but I’ll also outline other alternatives.

Thanks for reading!

JavaScript In Plain English

New JavaScript + Web Development articles every day.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store