Skip to content

Instantly share code, notes, and snippets.

@loilo

loilo/readme.md

Last active Sep 26, 2020
Embed
What would you like to do?
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)]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.