Hone logo
Hone
Problems

Implementing Async Computed Properties in Vue.js with TypeScript

This challenge focuses on building a robust and efficient way to handle asynchronous operations within Vue.js computed properties using TypeScript. You'll learn how to create a reusable mechanism that gracefully manages loading states, potential errors, and ensures that only the latest asynchronous result is used, preventing race conditions.

Problem Description

Your task is to create a reusable Vue 3 composable function (using the Composition API) in TypeScript that allows you to define "async computed properties." These are computed properties that rely on an asynchronous operation (like fetching data from an API) to determine their value.

The composable should:

  • Accept an asynchronous function as input.
  • Manage the loading state of the asynchronous operation.
  • Manage any errors that occur during the asynchronous operation.
  • Return a reactive object containing the current value, a loading state, and an error state.
  • Ensure that if the underlying dependencies change and the async function is re-executed, only the result from the latest execution is considered, discarding any older, slower results.

Key Requirements:

  1. Composable Function: Create a function useAsyncComputed<T>(asyncFn: () => Promise<T>, options?: AsyncComputedOptions): AsyncComputedRef<T>
  2. Return Value: The composable should return an object with the following reactive properties:
    • data: The resolved value of the promise, or null initially and if an error occurs.
    • loading: A boolean indicating if the asynchronous operation is currently in progress.
    • error: An Error object if the operation failed, otherwise null.
  3. Reactivity: The returned data, loading, and error should be Vue 3 ref objects.
  4. Dependency Tracking: The asynchronous operation should be re-executed whenever any of its reactive dependencies (e.g., other Vue refs or computed properties used inside asyncFn) change.
  5. Cancellation/Race Condition Prevention: Implement a mechanism to ignore results from older, slower asynchronous operations if a new one is triggered before the older one completes.
  6. Options (Optional):
    • initialData: A value to use for data before the first asynchronous operation completes.
    • onError: A callback function (error: Error) => void that gets executed when an error occurs.

Expected Behavior:

  • Initially, data should be null (or initialData if provided), loading should be true, and error should be null.
  • Once the promise resolves, data should be updated with the resolved value, loading should become false, and error should be null.
  • If the promise rejects, data should remain null (or its previous valid value if initialData is not used and it's not the first call), loading should become false, and error should be updated with the rejection reason.
  • If dependencies change while an operation is in progress, the current operation should be effectively cancelled, and a new one should start. The UI should reflect the new loading state immediately.

Examples

Example 1: Basic Data Fetching

import { ref, onMounted } from 'vue';
import { useAsyncComputed } from './useAsyncComputed'; // Assuming you save the composable here

function fetchUserData(userId: number): Promise<{ id: number; name: string }> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId === 1) {
        resolve({ id: 1, name: 'Alice' });
      } else {
        reject(new Error('User not found'));
      }
    }, 1000);
  });
}

export default {
  setup() {
    const userId = ref(1);
    const { data, loading, error } = useAsyncComputed(() => fetchUserData(userId.value));

    // Example of updating userId reactively
    setTimeout(() => {
      userId.value = 2; // This should trigger a new async operation
    }, 3000);

    return {
      userId,
      userData: data,
      isLoading: loading,
      fetchError: error,
    };
  },
};

Output (after ~1 second, then userId changes and resolves/rejects):

  • Initially: userData: null, isLoading: true, fetchError: null
  • After 1 second (userId = 1): userData: { id: 1, name: 'Alice' }, isLoading: false, fetchError: null
  • After 3 seconds (userId = 2): userData: null, isLoading: true, fetchError: null (while new fetch is in progress)
  • After ~4 seconds (userId = 2): userData: null, isLoading: false, fetchError: Error('User not found')

Explanation: The useAsyncComputed composable takes the fetchUserData promise-returning function. When userId changes, fetchUserData is called again. The UI updates to show loading, and eventually the error state.

Example 2: Using initialData and onError

import { ref } from 'vue';
import { useAsyncComputed } from './useAsyncComputed';

function fetchConfig(): Promise<{ theme: string }> {
  return new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error('Failed to load config'));
    }, 1500);
  });
}

export default {
  setup() {
    const handleConfigError = (err: Error) => {
      console.error('Custom error handler:', err.message);
    };

    const { data, loading, error } = useAsyncComputed(
      () => fetchConfig(),
      {
        initialData: { theme: 'default' },
        onError: handleConfigError,
      }
    );

    return {
      config: data,
      isLoading: loading,
      configError: error,
    };
  },
};

Output (after ~1.5 seconds):

  • Initially: config: { theme: 'default' }, isLoading: true, configError: null
  • After 1.5 seconds: config: { theme: 'default' }, isLoading: false, configError: Error('Failed to load config')
  • Console log: Custom error handler: Failed to load config

Explanation: The initialData is displayed while loading. When an error occurs, the onError callback is executed, and the data remains the initialData until a successful fetch.

Constraints

  • The solution must be implemented in TypeScript.
  • The composable function should be compatible with Vue 3 Composition API.
  • The solution should be performant and avoid unnecessary re-renders.
  • Do not use external libraries specifically for async computed properties (e.g., vue-use/useAsyncState). The goal is to build this logic yourself.

Notes

  • Consider how to manage the AbortController or a similar mechanism to signal cancellation for the asynchronous operations.
  • Think about the initial state of the refs. When does loading become true? When does it become false?
  • Ensure that your solution correctly tracks reactive dependencies within the provided asyncFn. Vue's effectScope or watchEffect can be valuable here.
  • The asyncFn passed to your composable will be a function that returns a Promise. You'll need to await or .then() its result.
Loading editor...
typescript