Composables Worth Having

Fetching, persisting and listening as composables, plus when to install VueUse rather than maintain your own.

Syntaxconst { data, error, isLoading } = useJson<Post[]>('/data/posts.json')

Three composables turn up in almost every project. Writing them once is how the pattern sinks in, and it shows where the sharp edges are.

Fetching

A fetch composable returns data, error and isLoading refs and starts the request itself. Put the request inside watchEffect and read the URL with toValue, and it refetches when the URL changes. Use the onCleanup argument that watchEffect passes to abort the previous request; without it a slow first response can land after a fast second one and overwrite it.

Persisting

A storage composable reads localStorage once for the initial value, then watches the ref and writes back on change. Objects and arrays need deep: true, because a shallow watcher only fires when the whole value is replaced.

Listening

An event-listener composable takes an event name and a handler, adds the listener in onMounted and removes it in onUnmounted. It is the smallest useful composable and it prevents the most bugs, because the removal cannot be forgotten.

When not to write one

VueUse is a maintained collection of exactly these — useStorage, useEventListener, useFetch and several hundred more — and its versions cover cases yours will not, such as cross-tab storage events and reactive listener targets. Write your own to learn the shape or to avoid a dependency; take theirs for anything with edge cases.

Example

Example · vue
<script setup lang="ts">
import { useJson } from '../composables/useJson'

interface Post {
  id: number
  title: string
}

const { data, error, isLoading } = useJson<Post[]>('/data/posts.json')
</script>

<template>
  <p v-if="isLoading">Loading</p>
  <p v-else-if="error">{{ error.message }}</p>
  <ul v-else-if="data">
    <li v-for="post in data" :key="post.id">{{ post.title }}</li>
  </ul>
  <p v-else>No posts</p>
</template>

<style scoped>
li {
  line-height: 1.6;
}
</style>

When to use it

  • Load a JSON document into data, error and loading refs that a template can switch on.
  • Keep a user preference, such as a chosen theme or list density, across page reloads.
  • Add a global keyboard shortcut to a component and have it removed automatically on unmount.
  • Decide whether to install VueUse or keep a handful of small composables in the project.

More examples

The fetch composable

The URL is read with `toValue` inside `watchEffect`, so passing a ref or `() => `/data/${props.slug}.json`` refetches on change. `onCleanup` aborts the in-flight request before the next one starts, which is what keeps a stale response from overwriting a newer one. The `as Ref<T | null>` cast is needed because a generic ref's type would otherwise be unwrapped.

Example · ts
// src/composables/useJson.ts
import { ref, toValue, watchEffect, type MaybeRefOrGetter, type Ref } from 'vue'

export function useJson<T>(url: MaybeRefOrGetter<string>) {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  watchEffect((onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())

    data.value = null
    error.value = null
    isLoading.value = true

    fetch(toValue(url), { signal: controller.signal })
      .then((response) => response.json() as Promise<T>)
      .then((json) => {
        data.value = json
      })
      .catch((cause: unknown) => {
        if (controller.signal.aborted) return
        error.value = cause instanceof Error ? cause : new Error('Request failed')
      })
      .finally(() => {
        isLoading.value = false
      })
  })

  return { data, error, isLoading }
}

The local-storage composable

Read once on call, write on every change. The try/catch matters: a half-written or hand-edited value in storage would otherwise throw during setup and blank the component. `deep: true` is what makes nested edits, such as pushing to a stored array, trigger the write.

Example · ts
// src/composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'

export function useLocalStorage<T>(key: string, initial: T): Ref<T> {
  const stored = localStorage.getItem(key)
  let start = initial

  if (stored !== null) {
    try {
      start = JSON.parse(stored) as T
    } catch {
      localStorage.removeItem(key)
    }
  }

  const state = ref(start) as Ref<T>

  watch(
    state,
    (value) => {
      localStorage.setItem(key, JSON.stringify(value))
    },
    { deep: true }
  )

  return state
}

The event-listener composable

Ten lines that remove a whole class of leak. The `K extends keyof WindowEventMap` generic is what gives the handler a correctly typed event, so `event.key` type-checks for `keydown` and would not for `scroll`. VueUse's `useEventListener` is the same idea with reactive targets, multiple events and a returned stop function.

Example · ts
// src/composables/useEventListener.ts
import { onMounted, onUnmounted } from 'vue'

export function useEventListener<K extends keyof WindowEventMap>(
  event: K,
  handler: (event: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions
) {
  onMounted(() => window.addEventListener(event, handler, options))
  onUnmounted(() => window.removeEventListener(event, handler, options))
}

// In a component:
// useEventListener('keydown', (event) => {
//   if (event.key === 'Escape') close()
// })

Discussion

  • Be the first to comment on this lesson.