How (and When) to Share Reactive Properties in Composables

Learn the difference between stateless and stateful composable functions in Vue 3


One common use for Vue 3 composable functions is to act as a global state store. However, they can also be useful without a global state.

A stateless composable doesn't mean it has no state, but rather that the state lives inside the function and is recreated on each execution.

Stateless Composable

Here’s useAsync, a composable for handling async calls (part of the Composition API Workshop).

useAsync.tsTS
import { ref, type UnwrapRef } from 'vue';
export async function useAsync<T>(fn: () => Promise<T>) {
  const loading = ref(false);
  const error = ref<Error | null>(null);
  const result = ref<T | null>(null);
  try {
    result.value = (await fn()) as UnwrapRef<T>;
  } catch (e) {
    if (e instanceof Error) {
      error.value = e;
    }
  } finally {
    loading.value = false;
  }
  return { loading, error, result };
}

The reactive state properties loading, error, and result live inside useAsync, meaning they are recreated with each execution and are not shared.

Stateful Composable

Now, suppose we want to add an option to log all errors and provide a function to clear them. This functionality should live outside useAsync to be global and shared.

useAsync.tsTS
import { ref, type UnwrapRef } from 'vue';
// Global state
export const errors = ref<string[]>([]);
export const resetErrors = () => (errors.value = []);
interface Options {
  recordError?: boolean;
}
export async function useAsync<T>(fn: () => Promise<T>, options: Options = {}) {
  const { recordError = true } = options;
  const loading = ref(false);
  const error = ref<Error | null>(null);
  const result = ref<T | null>(null);
  try {
    result.value = (await fn()) as UnwrapRef<T>;
  } catch (e) {
    if (e instanceof Error) {
      error.value = e;
      recordError && errors.value.push(e.message);
    }
  } finally {
    loading.value = false;
  }
  return { loading, error, result };
}

Here, errors and resetErrors live outside useAsync, but are consumed inside. This is ideal for globally tracking all failed operations.

Conclusion

A composable can have both global and local reactive state. The key is the scope: global (stateful) vs local (stateless).

Demo

Juan Andrés Núñez
Juan Andrés Núñez
Frontend Engineer. Vue.js Specialist. Professional Teacher. Stoic.