Skip to content

Best Practices for Error Handling in Vue Composables

Published: at 

Introduction

Navigating the complex world of composables presented a significant challenge. Understanding this powerful paradigm required effort when determining the division of responsibilities between a composable and its consuming component. The strategy for error handling emerged as a critical aspect that demanded careful consideration.

In this blog post, we aim to clear the fog surrounding this intricate topic. We’ll delve into the concept of Separation of Concerns, a fundamental principle in software engineering, and how it provides guidance for proficient error handling within the scope of composables. Let’s delve into this critical aspect of Vue composables and demystify it together.

“Separation of Concerns, even if not perfectly possible, is yet the only available technique for effective ordering of one’s thoughts, that I know of.” — Edsger W. Dijkstra

The usePokemon Composable

Our journey begins with the creation of a custom composable, aptly named usePokemon. This particular composable acts as a liaison between our application and the Pokémon API. It boasts three core methods — load, loadSpecies, and loadEvolution — each dedicated to retrieving distinct types of data.

A straightforward approach would allow these methods to propagate errors directly. Instead, we take a more robust approach. Each method catches potential exceptions internally and exposes them via a dedicated error object. This strategy enables more sophisticated and context-sensitive error handling within the components that consume this composable.

Without further ado, let’s delve into the TypeScript code for our usePokemon composable:

Dissecting the usePokemon Composable

Let’s break down our usePokemon composable step by step, to fully grasp its structure and functionality.

The ErrorRecord Interface and errorsFactory Function

interface ErrorRecord {
  ErrorRecord.load: Error | nullload: Error | null;
  ErrorRecord.loadSpecies: Error | nullloadSpecies: Error | null;
  ErrorRecord.loadEvolution: Error | nullloadEvolution: Error | null;
}

const const errorsFactory: () => ErrorRecorderrorsFactory = (): ErrorRecord => ({
  ErrorRecord.load: Error | nullload: null,
  ErrorRecord.loadSpecies: Error | nullloadSpecies: null,
  ErrorRecord.loadEvolution: Error | nullloadEvolution: null,
});

First, we define a ErrorRecord interface that encapsulates potential errors from our three core methods. This interface ensures that each method can store a Error object or null if no error has occurred.

The errorsFactory function creates these ErrorRecord objects. It returns an ErrorRecord with all values set to null, indicating no errors have occurred yet.

Initialising Refs

import { function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
, interface Ref<T = any, S = T>Ref } from 'vue'
const const pokemon: Ref<any, any>pokemon: interface Ref<T = any, S = T>Ref<any | null> = ref<null>(value: null): Ref<null, null> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(null);
const const species: Ref<any, any>species: interface Ref<T = any, S = T>Ref<any | null> = ref<null>(value: null): Ref<null, null> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(null);
const const evolution: Ref<any, any>evolution: interface Ref<T = any, S = T>Ref<any | null> = ref<null>(value: null): Ref<null, null> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(null);
const const error: Ref<ErrorRecord, ErrorRecord>error: interface Ref<T = any, S = T>Ref<ErrorRecord> =
ref<ErrorRecord>(value: ErrorRecord): Ref<{
    load: Error | null;
    loadSpecies: Error | null;
    loadEvolution: Error | null;
}, ErrorRecord | {
    load: Error | null;
    loadSpecies: Error | null;
    loadEvolution: Error | null;
}> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(const errorsFactory: () => ErrorRecorderrorsFactory());

Next, we create the Ref objects that store our data (pokemon, species, and evolution) and our error information (error). We use the errorsFactory function to set up the initial error-free state.

The load, loadSpecies, and loadEvolution Methods

Each of these methods performs a similar set of operations: it fetches data from a specific endpoint of the Pokémon API, assigns the returned data to the appropriate Ref object, and handles any potential errors.

const const load: (id: number) => Promise<void>load = async (id: numberid: number) => {
  try {
    const const response: Responseresponse = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch)
fetch
(`https://pokeapi.co/api/v2/pokemon/${id: numberid}`);
pokemon.value = await const response: Responseresponse.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)
json
();
error.value.load = null; } catch (function (local var) err: unknownerr) { error.value.load = function (local var) err: unknownerr as Error; } };

For example, in the load method, we fetch data from the pokemon endpoint using the provided ID. A successful fetch updates pokemon.value with the returned data and clears any previous error by setting error.value.load to null. When an error occurs during the fetch, we catch it and store it in error.value.load.

The loadSpecies and loadEvolution methods operate similarly, but they fetch from different endpoints and store their data and errors in different Ref objects.

The Return Object

The composable returns an object providing access to the Pokémon, species, and evolution data, as well as the three load methods. It exposes the error object as a computed property. This computed property updates whenever any of the methods sets an error, allowing consumers of the composable to react to errors.


return {
  pokemon: anypokemon,
  species: anyspecies,
  evolution: anyevolution,
  load: anyload,
  loadSpecies: anyloadSpecies,
  loadEvolution: anyloadEvolution,
  error: anyerror: computed(() => error.value),
};

Full Code


import { function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
, interface Ref<T = any, S = T>Ref,
const computed: {
    <T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ComputedRef<T>;
    <T, S = T>(options: WritableComputedOptions<T, S>, debugOptions?: DebuggerOptions): WritableComputedRef<T, S>;
}
computed
} from "vue";
interface ErrorRecord { ErrorRecord.load: Error | nullload: Error | null; ErrorRecord.loadSpecies: Error | nullloadSpecies: Error | null; ErrorRecord.loadEvolution: Error | nullloadEvolution: Error | null; } const const errorsFactory: () => ErrorRecorderrorsFactory = (): ErrorRecord => ({ ErrorRecord.load: Error | nullload: null, ErrorRecord.loadSpecies: Error | nullloadSpecies: null, ErrorRecord.loadEvolution: Error | nullloadEvolution: null, }); export default function
function usePokemon(): {
    pokemon: Ref<any, any>;
    species: Ref<any, any>;
    evolution: Ref<any, any>;
    load: (id: number) => Promise<void>;
    loadSpecies: (id: number) => Promise<void>;
    loadEvolution: (id: number) => Promise<...>;
    error: ComputedRef<...>;
}
usePokemon
() {
const const pokemon: Ref<any, any>pokemon: interface Ref<T = any, S = T>Ref<any | null> = ref<null>(value: null): Ref<null, null> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(null);
const const species: Ref<any, any>species: interface Ref<T = any, S = T>Ref<any | null> = ref<null>(value: null): Ref<null, null> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(null);
const const evolution: Ref<any, any>evolution: interface Ref<T = any, S = T>Ref<any | null> = ref<null>(value: null): Ref<null, null> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(null);
const const error: Ref<ErrorRecord, ErrorRecord>error: interface Ref<T = any, S = T>Ref<ErrorRecord> =
ref<ErrorRecord>(value: ErrorRecord): Ref<{
    load: Error | null;
    loadSpecies: Error | null;
    loadEvolution: Error | null;
}, ErrorRecord | {
    load: Error | null;
    loadSpecies: Error | null;
    loadEvolution: Error | null;
}> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(const errorsFactory: () => ErrorRecorderrorsFactory());
const const load: (id: number) => Promise<void>load = async (id: numberid: number) => { try { const const response: Responseresponse = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch)
fetch
(`https://pokeapi.co/api/v2/pokemon/${id: numberid}`);
const pokemon: Ref<any, any>pokemon.Ref<any, any>.value: anyvalue = await const response: Responseresponse.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)
json
();
const error: Ref<ErrorRecord, ErrorRecord>error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecordvalue.ErrorRecord.load: Error | nullload = null; } catch (function (local var) err: unknownerr) { const error: Ref<ErrorRecord, ErrorRecord>error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecordvalue.ErrorRecord.load: Error | nullload = function (local var) err: unknownerr as Error; } }; const const loadSpecies: (id: number) => Promise<void>loadSpecies = async (id: numberid: number) => { try { const const response: Responseresponse = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch)
fetch
(
`https://pokeapi.co/api/v2/pokemon-species/${id: numberid}` ); const species: Ref<any, any>species.Ref<any, any>.value: anyvalue = await const response: Responseresponse.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)
json
();
const error: Ref<ErrorRecord, ErrorRecord>error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecordvalue.ErrorRecord.loadSpecies: Error | nullloadSpecies = null; } catch (function (local var) err: unknownerr) { const error: Ref<ErrorRecord, ErrorRecord>error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecordvalue.ErrorRecord.loadSpecies: Error | nullloadSpecies = function (local var) err: unknownerr as Error; } }; const const loadEvolution: (id: number) => Promise<void>loadEvolution = async (id: numberid: number) => { try { const const response: Responseresponse = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch)
fetch
(
`https://pokeapi.co/api/v2/evolution-chain/${id: numberid}` ); const evolution: Ref<any, any>evolution.Ref<any, any>.value: anyvalue = await const response: Responseresponse.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)
json
();
const error: Ref<ErrorRecord, ErrorRecord>error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecordvalue.ErrorRecord.loadEvolution: Error | nullloadEvolution = null; } catch (function (local var) err: unknownerr) { const error: Ref<ErrorRecord, ErrorRecord>error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecordvalue.ErrorRecord.loadEvolution: Error | nullloadEvolution = function (local var) err: unknownerr as Error; } }; return { pokemon: Ref<any, any>pokemon, species: Ref<any, any>species, evolution: Ref<any, any>evolution, load: (id: number) => Promise<void>load, loadSpecies: (id: number) => Promise<void>loadSpecies, loadEvolution: (id: number) => Promise<void>loadEvolution, error: ComputedRef<ErrorRecord>error: computed<ErrorRecord>(getter: ComputedGetter<ErrorRecord>, debugOptions?: DebuggerOptions): ComputedRef<ErrorRecord> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the returned value from the getter. It can also take an object with get and set functions to create a writable ref object.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() => const error: Ref<ErrorRecord, ErrorRecord>error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecordvalue),
}; }

The Pokémon Component

Next, let’s look at a Pokémon component that uses our usePokemon composable:


<template>
  <div: HTMLAttributes & ReservedPropsdiv>
    <div: HTMLAttributes & ReservedPropsdiv v-if="const pokemon: anypokemon">
      <h2: HTMLAttributes & ReservedPropsh2>Pokemon Data:</h2: HTMLAttributes & ReservedPropsh2>
      <p: HTMLAttributes & ReservedPropsp>Name: {{ const pokemon: anypokemon.name }}</p: HTMLAttributes & ReservedPropsp>
    </div: HTMLAttributes & ReservedPropsdiv>

    <div: HTMLAttributes & ReservedPropsdiv v-if="const species: anyspecies">
      <h2: HTMLAttributes & ReservedPropsh2>Species Data:</h2: HTMLAttributes & ReservedPropsh2>
      <p: HTMLAttributes & ReservedPropsp>Name: {{ const species: anyspecies.base_happiness }}</p: HTMLAttributes & ReservedPropsp>
    </div: HTMLAttributes & ReservedPropsdiv>

    <div: HTMLAttributes & ReservedPropsdiv v-if="const evolution: anyevolution">
      <h2: HTMLAttributes & ReservedPropsh2>Evolution Data:</h2: HTMLAttributes & ReservedPropsh2>
      <p: HTMLAttributes & ReservedPropsp>Name: {{ const evolution: anyevolution.evolutionName }}</p: HTMLAttributes & ReservedPropsp>
    </div: HTMLAttributes & ReservedPropsdiv>

    <div: HTMLAttributes & ReservedPropsdiv v-if="const loadError: ComputedRef<any>loadError">
      An error occurred while loading the pokemon: {{ const loadError: ComputedRef<any>loadError.message }}
    </div: HTMLAttributes & ReservedPropsdiv>

    <div: HTMLAttributes & ReservedPropsdiv v-if="const loadSpeciesError: ComputedRef<any>loadSpeciesError">
      An error occurred while loading the species:
      {{ const loadSpeciesError: ComputedRef<any>loadSpeciesError.message }}
    </div: HTMLAttributes & ReservedPropsdiv>

    <div: HTMLAttributes & ReservedPropsdiv v-if="const loadEvolutionError: ComputedRef<any>loadEvolutionError">
      An error occurred while loading the evolution:
      {{ const loadEvolutionError: ComputedRef<any>loadEvolutionError.message }}
    </div: HTMLAttributes & ReservedPropsdiv>
  </div: HTMLAttributes & ReservedPropsdiv>
</template>

<script lang="ts" setup>
import { function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
,
const computed: {
    <T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ComputedRef<T>;
    <T, S = T>(options: WritableComputedOptions<T, S>, debugOptions?: DebuggerOptions): WritableComputedRef<T, S>;
}
computed
} from "vue";
import import usePokemonusePokemon from "@/composables/usePokemon"; const { const load: anyload, const loadSpecies: anyloadSpecies, const loadEvolution: anyloadEvolution, const pokemon: anypokemon, const species: anyspecies, const evolution: anyevolution, const error: anyerror } = import usePokemonusePokemon(); const const loadError: ComputedRef<any>loadError = computed<any>(getter: ComputedGetter<any>, debugOptions?: DebuggerOptions): ComputedRef<any> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the returned value from the getter. It can also take an object with get and set functions to create a writable ref object.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() => const error: anyerror.value.load);
const const loadSpeciesError: ComputedRef<any>loadSpeciesError = computed<any>(getter: ComputedGetter<any>, debugOptions?: DebuggerOptions): ComputedRef<any> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the returned value from the getter. It can also take an object with get and set functions to create a writable ref object.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() => const error: anyerror.value.loadSpecies);
const const loadEvolutionError: ComputedRef<any>loadEvolutionError = computed<any>(getter: ComputedGetter<any>, debugOptions?: DebuggerOptions): ComputedRef<any> (+1 overload)
Takes a getter function and returns a readonly reactive ref object for the returned value from the getter. It can also take an object with get and set functions to create a writable ref object.
@example```js // Creating a readonly computed ref: const count = ref(1) const plusOne = computed(() => count.value + 1) console.log(plusOne.value) // 2 plusOne.value++ // error ``` ```js // Creating a writable computed ref: const count = ref(1) const plusOne = computed({ get: () => count.value + 1, set: (val) => { count.value = val - 1 } }) plusOne.value = 1 console.log(count.value) // 0 ```@paramgetter - Function that produces the next value.@paramdebugOptions - For debugging. See {@link https://vuejs.org/guide/extras/reactivity-in-depth.html#computed-debugging}.@see{@link https://vuejs.org/api/reactivity-core.html#computed}
computed
(() => const error: anyerror.value.loadEvolution);
const const pokemonId: Ref<number, number>pokemonId = ref<number>(value: number): Ref<number, number> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(1);
const const speciesId: Ref<number, number>speciesId = ref<number>(value: number): Ref<number, number> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(1);
const const evolutionId: Ref<number, number>evolutionId = ref<number>(value: number): Ref<number, number> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which has a single property `.value` that points to the inner value.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
(1);
const load: anyload(const pokemonId: Ref<number, number>pokemonId.Ref<number, number>.value: numbervalue); const loadSpecies: anyloadSpecies(const speciesId: Ref<number, number>speciesId.Ref<number, number>.value: numbervalue); const loadEvolution: anyloadEvolution(const evolutionId: Ref<number, number>evolutionId.Ref<number, number>.value: numbervalue); </script>

The above code uses the usePokemon composable to fetch and display Pokémon, species, and evolution data. The component shows errors to users when fetch operations fail.

Conclusion

Wrapping the fetch operations in a try-catch block in the composable and surfacing errors through a reactive error object keeps the component clean and focused on its core responsibilities - presenting data and handling user interaction.

This approach promotes separation of concerns - the composable manages error handling logic independently, while the component responds to the provided state. The component remains focused on presenting the data effectively.

The error object’s reactivity integrates seamlessly with Vue’s template system. The system tracks changes automatically, updating relevant template sections when the error state changes.

This pattern offers a robust approach to error handling in composables. By centralizing error-handling logic in the composable, you create components that maintain clarity, readability, and maintainability.

Questions or thoughts?

Follow me on X for more TypeScript, Vue, and web dev insights! Feel free to DM me with:

  • Questions about this article
  • Topic suggestions
  • Feedback or improvements
Connect on X

Related Posts