Skip to content

Vue useDataLoader Composable

A reactive wrapper for async data loading. Encapsulates loaded data, loading and error states, and a load() method in a single reactive object. The constructor returns a reactive proxy so all properties are tracked by Vue's reactivity system automatically.

When to use

Use this composable when a component needs to fetch data asynchronously and drive its template from the result — showing a loading indicator while the request is in flight, an error message on failure, and the data on success.

The pattern

Create a DataLoader with a load function that returns a promise. The loader exposes reactive data, loading, loaded, and error properties for driving template conditionals.

Load on mount

Pass immediate: true to fire the load function during construction.

javascript
const users = useDataLoader({
  load() {
    return fetch("/api/users").then((r) => r.json());
  },
  immediate: true,
});

The template drives conditional rendering from the loader's reactive properties — loading, error, and data each gate a different UI state.

vue
<template>
  <div v-if="users.loading">Loading...</div>
  <div v-else-if="users.error">
    {{ users.error.message }}
  </div>
  <ul v-else>
    <li v-for="user in users.data">{{ user.name }}</li>
  </ul>
</template>

Manual reload

Call load() explicitly to fetch data on demand or refresh after changes.

javascript
const report = useDataLoader({
  load() {
    return fetchReport(filters);
  },
});

// Load initially
await report.load();

// Reload after filters change
async function applyFilters() {
  await report.load();
}

Implementing the DataLoader

A DataLoader class creates a reactive proxy of itself in the constructor. The useDataLoader composable is a thin wrapper that instantiates it.

javascript
import assert from "assert";
import { isProxy, reactive } from "vue";

class DataLoader {
  static get defaults() {
    return { initialData: null, immediate: false };
  }
  
  data = null;
  loading = false;
  loaded = false;
  error = null;
  promise = null;
  
  constructor(options) {
    const proxy = reactive(this);
    proxy.init(options);
    return proxy;
  }
  
  init(options) {
    assert(isProxy(this), new Error("`this` must be a reactive proxy"));
    this.options = {
      ...DataLoader.defaults,
      ...options,
    };
    assert(
      typeof this.options.load === "function",
      new TypeError("options.load must be a function"),
    );
    this.data = this.options.initialData;
    if (this.options.immediate) {
      this.load();
    }
  }
  
  async load() {
    try {
      this.loading = true;
      this.error = null;
      this.promise = this.options.load();
      assert(
        this.promise instanceof Promise,
        new TypeError("options.load must return a Promise"),
      );
      this.data = await this.promise;
      this.loading = false;
      this.loaded = true;
    } catch (error) {
      this.error = error;
      this.loading = false;
      this.loaded = false;
      throw error;
    }
  }
}

export function useDataLoader(options) {
  return new DataLoader(options);
}

Reactive proxy in constructor

The constructor calls reactive(this) and returns the proxy, so the caller always gets a reactive object. The init() method is called on the proxy (not the raw instance) to ensure mutations are tracked. The isProxy guard in init() catches the mistake of calling it directly on a non-reactive instance.

The immediate option

When true, load() fires in the constructor. When false (the default), the caller triggers loading manually or in onMounted.

Error recovery

On failure, error holds the exception and loaded stays false. The caller can retry by calling load() again, which resets error to null before starting the new request.

Trade-offs

  • Class-based reactive proxy — Returning reactive(this) from a constructor is non-obvious. It works because Vue's reactive() returns a Proxy that intercepts property access on the class instance. The downside is that instanceof DataLoader checks fail on the proxy.
  • No cancellation — If load() is called while a previous load is in progress, both promises run concurrently. The last one to resolve wins. For debounced or cancellable loading, wrap the load function with an AbortController.
  • No caching — Every load() call hits the data source. For cached or deduplicated fetching, layer a cache in the load function or use a dedicated data fetching library.