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 Pokémon 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 {
ErrorRecord.load: Error | null
load: Error | null;
ErrorRecord.loadSpecies: Error | null
loadSpecies: Error | null;
ErrorRecord.loadEvolution: Error | null
loadEvolution: Error | null;
}
const const errorsFactory: () => ErrorRecord
errorsFactory = (): ErrorRecord => ({
ErrorRecord.load: Error | null
load: null,
ErrorRecord.loadSpecies: Error | null
loadSpecies: null,
ErrorRecord.loadEvolution: Error | null
loadEvolution: null,
});
Firstly, 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.
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
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.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.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.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.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.ref(const errorsFactory: () => ErrorRecord
errorsFactory());
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 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: number
id: number) => {
try {
const const response: Response
response = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch)fetch(`https://pokeapi.co/api/v2/pokemon/${id: number
id}`);
pokemon.value = await const response: Response
response.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: unknown
err) {
error.value.load = function (local var) err: unknown
err as Error;
}
};
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 Pokémon, 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: any
pokemon,
species: any
species,
evolution: any
evolution,
load: any
load,
loadSpecies: any
loadSpecies,
loadEvolution: any
loadEvolution,
error: any
error: 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.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 | null
load: Error | null;
ErrorRecord.loadSpecies: Error | null
loadSpecies: Error | null;
ErrorRecord.loadEvolution: Error | null
loadEvolution: Error | null;
}
const const errorsFactory: () => ErrorRecord
errorsFactory = (): ErrorRecord => ({
ErrorRecord.load: Error | null
load: null,
ErrorRecord.loadSpecies: Error | null
loadSpecies: null,
ErrorRecord.loadEvolution: Error | null
loadEvolution: 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.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.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.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.ref(const errorsFactory: () => ErrorRecord
errorsFactory());
const const load: (id: number) => Promise<void>
load = async (id: number
id: number) => {
try {
const const response: Response
response = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch)fetch(`https://pokeapi.co/api/v2/pokemon/${id: number
id}`);
const pokemon: Ref<any, any>
pokemon.Ref<any, any>.value: any
value = await const response: Response
response.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: ErrorRecord
value.ErrorRecord.load: Error | null
load = null;
} catch (function (local var) err: unknown
err) {
const error: Ref<ErrorRecord, ErrorRecord>
error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecord
value.ErrorRecord.load: Error | null
load = function (local var) err: unknown
err as Error;
}
};
const const loadSpecies: (id: number) => Promise<void>
loadSpecies = async (id: number
id: number) => {
try {
const const response: Response
response = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch)fetch(
`https://pokeapi.co/api/v2/pokemon-species/${id: number
id}`
);
const species: Ref<any, any>
species.Ref<any, any>.value: any
value = await const response: Response
response.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: ErrorRecord
value.ErrorRecord.loadSpecies: Error | null
loadSpecies = null;
} catch (function (local var) err: unknown
err) {
const error: Ref<ErrorRecord, ErrorRecord>
error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecord
value.ErrorRecord.loadSpecies: Error | null
loadSpecies = function (local var) err: unknown
err as Error;
}
};
const const loadEvolution: (id: number) => Promise<void>
loadEvolution = async (id: number
id: number) => {
try {
const const response: Response
response = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch)fetch(
`https://pokeapi.co/api/v2/evolution-chain/${id: number
id}`
);
const evolution: Ref<any, any>
evolution.Ref<any, any>.value: any
value = await const response: Response
response.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: ErrorRecord
value.ErrorRecord.loadEvolution: Error | null
loadEvolution = null;
} catch (function (local var) err: unknown
err) {
const error: Ref<ErrorRecord, ErrorRecord>
error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecord
value.ErrorRecord.loadEvolution: Error | null
loadEvolution = function (local var) err: unknown
err 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.computed(() => const error: Ref<ErrorRecord, ErrorRecord>
error.Ref<ErrorRecord, ErrorRecord>.value: ErrorRecord
value),
};
}
The Pokémon Component
Next, let’s look at a Pokémon component that uses our usePokemon
composable:
<template>
<div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-if="const pokemon: any
pokemon">
<h2: HTMLAttributes & ReservedProps
h2>Pokemon Data:</h2: HTMLAttributes & ReservedProps
h2>
<p: HTMLAttributes & ReservedProps
p>Name: {{ const pokemon: any
pokemon.name }}</p: HTMLAttributes & ReservedProps
p>
</div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-if="const species: any
species">
<h2: HTMLAttributes & ReservedProps
h2>Species Data:</h2: HTMLAttributes & ReservedProps
h2>
<p: HTMLAttributes & ReservedProps
p>Name: {{ const species: any
species.base_happiness }}</p: HTMLAttributes & ReservedProps
p>
</div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-if="const evolution: any
evolution">
<h2: HTMLAttributes & ReservedProps
h2>Evolution Data:</h2: HTMLAttributes & ReservedProps
h2>
<p: HTMLAttributes & ReservedProps
p>Name: {{ const evolution: any
evolution.evolutionName }}</p: HTMLAttributes & ReservedProps
p>
</div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-if="const loadError: ComputedRef<any>
loadError">
An error occurred while loading the pokemon: {{ const loadError: ComputedRef<any>
loadError.message }}
</div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-if="const loadSpeciesError: ComputedRef<any>
loadSpeciesError">
An error occurred while loading the species:
{{ const loadSpeciesError: ComputedRef<any>
loadSpeciesError.message }}
</div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-if="const loadEvolutionError: ComputedRef<any>
loadEvolutionError">
An error occurred while loading the evolution:
{{ const loadEvolutionError: ComputedRef<any>
loadEvolutionError.message }}
</div: HTMLAttributes & ReservedProps
div>
</div: HTMLAttributes & ReservedProps
div>
</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.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 usePokemon
usePokemon from "@/composables/usePokemon";
const { const load: any
load, const loadSpecies: any
loadSpecies, const loadEvolution: any
loadEvolution, const pokemon: any
pokemon, const species: any
species, const evolution: any
evolution, const error: any
error } =
import usePokemon
usePokemon();
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.computed(() => const error: any
error.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.computed(() => const error: any
error.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.computed(() => const error: any
error.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.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.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.ref(1);
const load: any
load(const pokemonId: Ref<number, number>
pokemonId.Ref<number, number>.value: number
value);
const loadSpecies: any
loadSpecies(const speciesId: Ref<number, number>
speciesId.Ref<number, number>.value: number
value);
const loadEvolution: any
loadEvolution(const evolutionId: Ref<number, number>
evolutionId.Ref<number, number>.value: number
value);
</script>
The above code uses the usePokemon composable to fetch and display Pokémon, 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.