Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active December 10, 2024 17:42
Show Gist options
  • Save loilo/fbe3124108a46ff50ab1b867bb5b4bf9 to your computer and use it in GitHub Desktop.
Save loilo/fbe3124108a46ff50ab1b867bb5b4bf9 to your computer and use it in GitHub Desktop.
Async Computed Values for Vue 3

Async Computed Values for Vue 3

This gist provides a useAsyncComputed function which allows to create asynchronous dependencies on reactive values (using Vue 3's Composition API).

Requires at least Vue 3.0 and TypeScript 4.0.

Usage

Basic Usage

import { ref } from 'vue'
import useAsyncComputed from './use-async-computed'

const packageName = ref('color-blend')

function getDownloads() {
  return fetch(`https://api.npmjs.org/downloads/point/last-week/${packageName.value}`)
    .then(response => response.json())
    .then(result => result.downloads)
}

const [downloads] = useAsyncComputed(getDownloads, 0)

Whenever packageName is updated, the downloads will stay the same until the returned computed promise resolves.

Default Value

The second parameter passed to the useAsyncComputed function is optional. If provided, it is used as the initial value held by the computed ref until its first evaluation resolves.

Loading State

The return value of useAsyncComputed is a two-item tuple. While the first item holds the ref containing the latest evaluation result, the second item is a boolean ref which tells whether the computed ref is currently being (re-)evaluated.

const [downloads, isGettingDownloads] = useAsyncComputed(getDownloads, 0)

This may be used for displaying a loading indicator instead of the stale downloads value when the packageName changes.

React to Cancellation

The asyny computed callback receives an onCancel function as its first argument. It can be invoked with a callback which will be executed when a re-evaluation of the async computed value is triggered before the current has finished.

This can be used to clean up resources used for evaluation, e.g. we could cancel fetch requests to the npm API by adjusting our getDownloads function from the introductory example:

function getDownloads(onCancel) {
  const abortController = new AbortController()

  onCancel(() => {
    abortController.abort()
  })

  return fetch(`https://api.npmjs.org/downloads/point/last-week/${packageName.value}`, {
    signal: abortController.signal
  })
    .then(response => response.json())
    .then(result => result.downloads)
}

Caveats

  • Just like Vue's built-in computed function, useAsyncComputed does dependency tracking and is automatically re-evaluated when dependencies change.

    Note however that only dependencies referenced in the first call stack are considered for this. In other words: Dependencies which are accessed asynchronously are not triggering re-evaluation of the async computed value.

  • As opposed to Vue's built-in computed function, re-evaluation of the async computed value is triggered whenever dependencies are changing, regardless whether its result is being used or not.

import { ref, readonly, watchEffect, Ref, DeepReadonly } from 'vue'
/**
* Handle overlapping async evaluations
*
* @param cancelCallback The provided callback is invoked when a re-evaluation of the computed value is triggered before the previous one finished
*/
export type AsyncComputedOnCancel = (cancelCallback: () => void) => void
export type AsyncComputedResult<T> = [
value: DeepReadonly<Ref<T>>,
evaluating: DeepReadonly<Ref<boolean>>
]
/**
* Create an asynchronous computed dependency
*
* @param callback The promise-returning callback which generates the computed value
* @param defaultValue A default value, used until the first evaluation finishes
* @returns A two-item tuple with the first item being a readonly ref to the computed value and the second item holding a boolean ref, indicating whether the async computed value is currently (re-)evaluated
*/
export default function useAsyncComputed<T>(
callback: (onCancel: AsyncComputedOnCancel) => T | Promise<T>,
defaultValue?: T
): AsyncComputedResult<T> {
let counter = 0
const current = ref(defaultValue) as Ref<T>
const evaluating = ref<boolean>(false)
watchEffect(async onInvalidate => {
counter++
const counterAtBeginning = counter
let hasFinished = false
try {
// Defer initial setting of `evaluating` ref
// to avoid having it as a dependency
Promise.resolve().then(() => {
evaluating.value = true
})
const result = await callback(cancelCallback => {
onInvalidate(() => {
evaluating.value = false
if (!hasFinished) {
cancelCallback()
}
})
})
if (counterAtBeginning === counter) {
current.value = result
}
} finally {
evaluating.value = false
hasFinished = true
}
})
return [readonly(current), readonly(evaluating)]
}
@andriika
Copy link

andriika commented Mar 4, 2021

Had the same problem, computed properties in vue 3 are lazy and cached, and therefore great fit for async fetches. Sadly they do not support Promise returnsout of the box. Luckily there is a solution using vue's reactive components, so I created https://www.npmjs.com/package/vue3-async-computed plugin. Hope it helps. Usage is simple:

import asyncomputed from 'vue3-async-computed';

Vue.createApp({

    data() {
        return {
            userID: 1,
        }
    },

}).use(asyncomputed, {

    async profile() {
        const url = `https://httpbin.org/get?userID=${this.userID}`;
        return fetch(url).then(r => r.json());
    },

}).mount('#app');

Here profile property depends on userID and it's asynchronously computed when userID changes.

@Makio64
Copy link

Makio64 commented Sep 9, 2022

I'm adding a direct link to the code of @andriika
https://github.com/mainclass/vue3-async-computed/blob/master/index.js
https://github.com/mainclass/vue3-async-computed/

The plugin itself :

export function createPlugin({ ref }) {
    function plugin(app, ops) {
        const methods = {};
        const computed = {};

        for (let [key, func] of Object.entries(ops)) {

            // 1. add original function to methods

            const $fetch = 'asyncomp_fetch_' + key;
            methods[$fetch] = func;

            // 2. create computed property that uses original function to asynchronously fetch the value
            // and asssign it to the Vue.ref instance

            const $ref = ref(null);
            function cfetch() {
                this[$fetch]($ref);
                return $ref;
            }

            // 3. create computed propery with the original function name that uses previous computed property
            // to return Vue's ref.value

            const $cfetch = 'asyncomp_cfetch_' + key;
            computed[$cfetch] = cfetch;

            function comp() {
                return this[$cfetch].value;
            }
            computed[key] = comp;
        }
        app.mixin({ methods, computed });
    }
    return plugin
}

I think this solution is very clever and use the core concept of vue3 efficiently

@shaokeyibb
Copy link

bro you just save my life, that's wonderful

@loilo
Copy link
Author

loilo commented Oct 4, 2022

@shaokeyibb Nowadays I'd recommend to use VueUse instead though. It has all the utilities you could wish for in Vue, and its computedAsync function is actually stemming from this Gist. 🙂

@shaokeyibb
Copy link

@shaokeyibb Nowadays I'd recommend to use VueUse instead though. It has all the utilities you could wish for in Vue, and its computedAsync function is actually stemming from this Gist. 🙂

wow, make lots of sense for me, thank you!

@Makio64
Copy link

Makio64 commented Oct 4, 2022

@shaokeyibb Nowadays I'd recommend to use VueUse instead though. It has all the utilities you could wish for in Vue, and its computedAsync function is actually stemming from this Gist. 🙂

I didnt know vueUse, so many great function, thanks for sharing <3

@loilo
Copy link
Author

loilo commented Oct 4, 2022

It's literally the best, you'll never want to go back. 😁
If you want to explore more great Vue stuff, check out the GitHub profile of Anthony Fu, VueUse's author. Things like unplugin-auto-import are just magic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment