DEV Community

Cover image for Best Practices for Error Handling in Vue Composables
Alexander Opalic
Alexander Opalic

Posted on • Edited on • Originally published at alexop.dev

Best Practices for Error Handling in Vue Composables

Introduction

Navigating the complex world of composables initially posed quite the challenge. Understanding this powerful paradigm was a task in itself, let alone discerning the division of responsibilities between a composable and its consuming component. A particular aspect of this division, the strategy for error handling, took a fair share of time to get right.

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 Pokemon API. It boasts three core methods — load, loadSpecies, and loadEvolution — each dedicated to retrieving distinct types of data.

At first glance, you might be tempted to let these methods propagate errors directly. However, in the spirit of enhanced error management, we adopt a different approach. Each method is engineered to catch potential exceptions internally and expose them via a dedicated error object. This shift in strategy opens the door for 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 {
  load: Error | null
  loadSpecies: Error | null
  loadEvolution: Error | null
}

const errorsFactory = (): ErrorRecord => ({
  load: null,
  loadSpecies: null,
  loadEvolution: null,
})
Enter fullscreen mode Exit fullscreen mode

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

To facilitate the creation of these ErrorRecord objects, we implement the errorsFactory function. This function initialises an ErrorRecord with all values set to null, signifying that no errors have occurred yet.

Initialising Refs

const pokemon: Ref<any | null> = ref(null)
const species: Ref<any | null> = ref(null)
const evolution: Ref<any | null> = ref(null)
const error: Ref<ErrorRecord> = ref(errorsFactory())
Enter fullscreen mode Exit fullscreen mode

Next, we initialise the Ref objects that will store our data (pokemon, species, and evolution) and our error information (error). The latter is initialised using the errorsFactory function to provide an 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 Pokemon API, assigns the returned data to the appropriate Ref object, and handles any potential errors.

const load = async (id: number) => {
  try {
    const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
    pokemon.value = await response.json()
    error.value.load = null
  } catch (err) {
    error.value.load = err as Error
  }
}
Enter fullscreen mode Exit fullscreen mode

For example, in the load method, we attempt to fetch data from the pokemon endpoint using the provided id. If the fetch is successful, we assign the returned data to pokemon.value and clear any previous error by setting error.value.load to null. However, if an error occurs during the fetch, we catch the error and assign it to 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

Finally, our composable returns an object providing access to the pokemon, species, and evolution data, as well as the three load methods. Importantly, it also exposes the error object as a computed property. This computed property will automatically update whenever any of the methods sets an error, allowing consumers of the composable to reactively respond to any errors that may occur.

return {
  pokemon,
  species,
  evolution,
  load,
  loadSpecies,
  loadEvolution,
  error: computed(() => error.value),
}
Enter fullscreen mode Exit fullscreen mode

Full Code

import { ref, Ref, computed } from 'vue'

interface ErrorRecord {
  load: Error | null
  loadSpecies: Error | null
  loadEvolution: Error | null
}

const errorsFactory = (): ErrorRecord => ({
  load: null,
  loadSpecies: null,
  loadEvolution: null,
})

export default function usePokemon() {
  const pokemon: Ref<any | null> = ref(null)
  const species: Ref<any | null> = ref(null)
  const evolution: Ref<any | null> = ref(null)
  const error: Ref<ErrorRecord> = ref(errorsFactory())

  const load = async (id: number) => {
    try {
      const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
      pokemon.value = await response.json()
      error.value.load = null
    } catch (err) {
      error.value.load = err as Error
    }
  }

  const loadSpecies = async (id: number) => {
    try {
      const response = await fetch(
        `https://pokeapi.co/api/v2/pokemon-species/${id}`
      )
      species.value = await response.json()
      error.value.loadSpecies = null
    } catch (err) {
      error.value.loadSpecies = err as Error
    }
  }

  const loadEvolution = async (id: number) => {
    try {
      const response = await fetch(
        `https://pokeapi.co/api/v2/evolution-chain/${id}`
      )
      evolution.value = await response.json()
      error.value.loadEvolution = null
    } catch (err) {
      error.value.loadEvolution = err as Error
    }
  }

  return {
    pokemon,
    species,
    evolution,
    load,
    loadSpecies,
    loadEvolution,
    error: computed(() => error.value),
  }
}
Enter fullscreen mode Exit fullscreen mode

The Pokemon Component

Next, let's look at a Pokemon component that uses our usePokemon composable:

<template>
  <div>
    <div v-if="pokemon">
      <h2>Pokemon Data:</h2>
      <p>Name: {{ pokemon.name }}</p>
    </div>

    <div v-if="species">
      <h2>Species Data:</h2>
      <p>Name: {{ species.base_happiness }}</p>
    </div>

    <div v-if="evolution">
      <h2>Evolution Data:</h2>
      <p>Name: {{ evolution.evolutionName }}</p>
    </div>

    <div v-if="loadError">
      An error occurred while loading the pokemon: {{ loadError.message }}
    </div>

    <div v-if="loadSpeciesError">
      An error occurred while loading the species:
      {{ loadSpeciesError.message }}
    </div>

    <div v-if="loadEvolutionError">
      An error occurred while loading the evolution:
      {{ loadEvolutionError.message }}
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, computed } from 'vue'
import usePokemon from '@/composables/usePokemon'

const { load, loadSpecies, loadEvolution, pokemon, species, evolution, error } =
  usePokemon()

const loadError = computed(() => error.value.load)
const loadSpeciesError = computed(() => error.value.loadSpecies)
const loadEvolutionError = computed(() => error.value.loadEvolution)

const pokemonId = ref(1)
const speciesId = ref(1)
const evolutionId = ref(1)

load(pokemonId.value)
loadSpecies(speciesId.value)
loadEvolution(evolutionId.value)
</script>

Enter fullscreen mode Exit fullscreen mode

The above code uses the usePokemon composable to fetch and display pokemon, species, and evolution data. When a fetch operation fails, the error is displayed to the user.

Conclusion

Wrapping the fetch operations in a try-catch block in the composable and then surfacing any errors through a reactive error object allows the component to remain clean and focused on its own concerns - which is presenting data and handling user interaction.

This approach promotes separation of concerns - the composable doesn't need to know how errors are handled on the UI side, and the component doesn't need to worry about error handling logic. It just reacts to the state provided by the composable.

The error object, being reactive, integrates nicely with the vue template's reactivity system. It's automatically tracked, and any changes to the error object will cause the relevant parts of the template to update.

This pattern is a powerful way to handle errors in composables. By centralizing and encapsulating the error-handling logic in the composable, you can create components that are cleaner, easier to read, and more maintainable.


Enjoyed this post? Follow me on X for more Vue and TypeScript content:

@AlexanderOpalic

Top comments (2)

Collapse
 
christher profile image
Christher Lenander • Edited

What is the reason you didn't put this lines of code in the composables return instead?

const loadError = computed(() => error.value.load)
const loadSpeciesError = computed(() => error.value.loadSpecies)
const loadEvolutionError = computed(() => error.value.loadEvolution)
Enter fullscreen mode Exit fullscreen mode

Like

  return {
    pokemon,
    ...
    loadError: computed(() => error.value.load)
    loadSpeciesError: computed(() => error.value.loadSpecies)
    loadEvolutionError: computed(() => error.value.loadEvolution)
  }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
alexanderop profile image
Alexander Opalic

Thanks for your great question! You've definitely got a point.

But personally, I like the idea of the composable just keeping track of errors in one place. This way, the component using it can decide what to do if an error happens, or even ignore it if that's best.

Imagine a complex composable with multiple functions - it might be messy if it returns too much stuff. I prefer keeping things tidy by putting all errors together and letting the component decide what to do next. But hey, both ways can work depending on the situation!