Composables — extracting logic into functions
As a component grows, state and the work that operates on it pile up inside script setup. A composable is where you extract that when you want the same combination in another component.
The official definition of a composable is "a function that leverages the Composition API to encapsulate and reuse stateful logic". In practice, the test for prefixing with use is whether it creates reactive state, or depends on the caller's context (a component or an effect scope). The latter covers functions that register hooks or scope cleanup, which comes with constraints on where you call them. A function that is neither is fine as a plain module.
What you'll learn in this chapter
- How to return values so the connection survives destructuring
- Write arguments so they accept a ref, a getter, or a plain value
- Register cleanup for side effects in a form that is not tied to a component
- Explain where composables can be called, and why
- Recognise what does not need to be a composable, such as a formatting function
The basic shape
Create state and return it along with functions that operate on it.
import { computed, ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
const increment = () => {
count.value++
}
const reset = () => {
count.value = initial
}
return { count, doubled, increment, reset }
}
Naming it in camelCase starting with use is the convention.
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, doubled, increment, reset } = useCounter(10)
</script>
A fresh ref is created on every call, so state is separate per component. Sharing it is covered in a later section.
Return a plain object containing refs
The convention the documentation gives is to "return a non-reactive plain object containing multiple refs". That way the connection survives destructuring on the calling side.
Return an object wrapped in reactive() as-is and it follows changes through properties, but breaks the moment you destructure. The ref is unwrapped as it comes out, leaving a plain value (the same reason as destructuring reactive in chapter 2).
Put it through toRefs when the internals use reactive
When there are several related pieces of state, writing the internals as a single reactive object reads better. In that case, return it through toRefs().
import { reactive, toRefs } from 'vue'
export function useStepper(initial = 0, step = 1) {
const state = reactive({ count: initial, step })
const increment = () => {
state.count += state.step
}
// convert to a form that survives destructuring before returning
return { ...toRefs(state), increment }
}
toRefs() creates refs pointing at the properties, so writes reach state even after destructuring. Only properties enumerable at the moment of the call are converted, so properties added later get no ref.
As far as the shape of the return value goes, the caller sees no difference. It is an implementation choice — lay the state out as refs, or gather it with reactive — and either way you arrive at "a plain object containing refs".
If the caller wants to write stepper.count, they wrap it on their side with reactive(useStepper()).
Accepting arguments as refs or getters
When passing a value to a composable, the caller sometimes holds a plain value and sometimes a ref. Chapter 6 said to design composables to take getters so a destructured prop can be passed. The MaybeRefOrGetter type and toValue() accept all of these together.
toValue() arrived in 3.3 and returns the value for a ref, the result of calling it for a function, and the argument itself otherwise.
toValue(ref('a')) // 'a'
toValue(() => 'b') // 'b'
toValue('c') // 'c'
To follow changes in the argument, call toValue() inside the watcher's callback. The ref read during normalisation is then registered as a dependency of the watcher.
watchEffect(() => {
// called inside, so changes to url are picked up
load(toValue(url))
})
Extract it outside first and the watcher does not hold url as a dependency, so overwriting url does not re-run it. This is confirmed by actual behaviour.
One thing to watch. MaybeRefOrGetter<T> is really a union that includes T (a plain value, the various refs, and a getter), so it accepts a plain value too. Chapter 6 said that "designing a composable to take a getter makes a call that passes a value fail type checking", but with MaybeRefOrGetter that detection stops working.
const { userId } = defineProps<{ userId: number }>()
// type-checks fine, but does not follow changes to userId
const { data } = useFetch(`/api/users/${userId}`)
Flexibility for the caller and catching mistakes in the type system do not come together. Narrowing arguments that must follow changes to () => T is safer, and MaybeRefOrGetter is the choice when "it may also be called with a static value".
The useFetch in this chapter uses MaybeRefOrGetter to match the official example, because calling it with a fixed URL is also expected — and in exchange, passing a value by mistake is not caught by the types. If the fetch target is always derived from props, narrowing to url: () => string is safer.
Cleaning up side effects
Anything registered with the outside world, such as an event listener or a timer, comes with the responsibility of removing it. What you register inside a composable, you remove inside the composable.
import { onMounted, onUnmounted } from 'vue'
export function useEventListener<K extends keyof WindowEventMap>(
event: K,
callback: (event: WindowEventMap[K]) => void,
) {
// defer reading window until after mount
onMounted(() => window.addEventListener(event, callback))
onUnmounted(() => window.removeEventListener(event, callback))
}
Why the read of window sits inside onMounted is covered in a later section.
Cleanup that is not tied to a component
onUnmounted is a component hook, so it cannot be used outside a component. onScopeDispose() registers cleanup on the currently active effect scope, and the documentation positions it as the replacement for onUnmounted in reusable functions.
import { onScopeDispose } from 'vue'
export function useTicker(onTick: () => void, interval = 1000) {
// started at call time, so it also starts under server-side rendering (chapter 9)
const id = setInterval(onTick, interval)
onScopeDispose(() => clearInterval(id))
}
A component's setup has a scope, so this form also runs its cleanup inside a component. On top of that, calling it inside effectScope() means scope.stop() removes it, so it works outside components too.
The reach of these two trades off against when things start. onMounted only works inside a component, so deferring the start to onMounted means that inside effectScope() you get a warning and it never starts at all. Start at call time instead and it works under effectScope(), but it also starts under server-side rendering (covered in chapter 9).
Which to choose depends on whether that composable will be used outside a component. If you accept it as component-only, confine the start to onMounted.
They do run in a different order from onUnmounted, though. Stopping the scope happens synchronously within the unmount work, while onUnmounted is called in a batch after rendering has been flushed. Put both on a parent and child and the orders come out reversed.
parent: dispose
child: dispose
child: onUnmounted
parent: onUnmounted
dispose goes from parent to child, onUnmounted from child to parent (the order in chapter 7 is the onUnmounted side). If your cleanup reads the child's state, that difference matters.
Calling it where there is no active scope produces a warning and registers no cleanup. From 3.5 you can silence the warning by passing true as the second argument, but only the warning goes away; nothing is registered either way.
Merely hiding a component under KeepAlive does not run onScopeDispose. As with onUnmounted in chapter 7, the scope is not stopped while it stays in the cache. To release resources while it is not displayed, use onDeactivated.
Where they can be called
The documentation says to call composables synchronously inside <script setup> or setup(). The reason is that hooks cannot be registered outside a context where the active component instance is determined, and watchers cannot be tied to the instance. A watcher that is not tied does not stop on unmount, and leaks.
That "synchronously" has exceptions.
After an await. The documentation says <script setup> is "the only place where you can call composables after an await". The compiler restores the instance context after asynchronous work. It is the same machinery as in chapter 7, and hooks registered inside the composable work the same way. A hand-written async setup() has no such restoration.
Inside a lifecycle hook. The documentation says they "can also be called in lifecycle hooks like onMounted() in some cases". When running a hook, Vue restores the instance as the current one before calling, so hooks registered inside are tied to the instance.
But some hooks are still in time and some are not. Call a composable inside onMounted and the onUnmounted inside it is registered and runs later, while its onMounted is registered but never runs, because that stage is already executing.
No warning is issued here. Vue accepts the registration as long as there is an instance, and does not check whether the corresponding hook has already run. Warnings only appear when there is no instance.
Using this route safely means limiting it to cases where registering cleanup is the only goal.
Forms that break on the server come next chapter
Everything so far is fine if you only run in the browser. Several of these break once placed in a setup that also renders on the server: reading window at call time, side effects not confined to onMounted, and state placed in module scope. They are covered together in chapter 9.
Writing useFetch
Pulling the points so far together, a data-fetching composable comes out like this.
import { shallowRef, toValue, watchEffect, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = shallowRef<T | null>(null)
const error = shallowRef<Error | null>(null)
const loading = shallowRef(false)
watchEffect((onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
data.value = null
error.value = null
loading.value = true
// call toValue inside the callback
fetch(toValue(url), { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json() as Promise<T>
})
.then((json) => {
// an aborted run writes nothing
if (controller.signal.aborted) return
data.value = json
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
error.value = e instanceof Error ? e : new Error(String(e))
})
.finally(() => {
if (controller.signal.aborted) return
loading.value = false
})
})
return { data, error, loading }
}
The abort work is handed to the onCleanup received as watchEffect's first parameter. When url changes and it re-runs, Vue calls the previous cleanup first, so the stale request is aborted. It is the same machinery as the watcher cleanup in chapter 5.
Checking signal.aborted before writing exists so that an aborted run writes nothing. Calling abort() makes fetch reject with an AbortError, so without this guard the error from a fetch nobody needs any more lands in error. The screen shows an error while a newer fetch is still in flight.
This guard also holds if you swap the fetch for another wrapper and forget to pass signal. abort() sets signal.aborted to true on the spot, so the check works regardless of whether the request actually stopped. The conditions under which cancellation takes effect are covered in detail in chapter 18.
shallowRef is chosen for reasons of both meaning and types.
In terms of meaning, fetched JSON is something you replace wholesale; you never overwrite its nested properties individually. ref makes the contents deeply reactive, which is wasted conversion on a large response.
In terms of types, the difference shows when you annotate the return type. If you declare that it returns Ref<T | null>, the return of ref<T | null>(null) is Ref<UnwrapRef<T> | null, ...>, and because UnwrapRef<T> widens to unknown against the type parameter, it is not assignable. The as Ref<T | null> you often see in write-ups is there to silence that. The ShallowRef<T | null> and shallowRef combination needs no cast.
When you leave the return type to inference, as in the code above, ref type-checks too. Types only become the reason once you choose a design with annotations; without them, choose shallowRef on the meaning argument alone.
On the calling side, passing the prop as a getter refetches when the prop changes.
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
type User = { id: number; name: string }
const props = defineProps<{ userId: number }>()
// passed as a getter, so a change to userId refetches
const { data: user, error, loading } = useFetch<User>(
() => `/api/users/${props.userId}`,
)
</script>
<template>
<p v-if="loading">Loading</p>
<p v-else-if="error">{{ error.message }}</p>
<p v-else>{{ user?.name }}</p>
</template>
How this composable behaves once placed in a setup that also renders on the server is covered in chapter 9.
Do not make a formatting function a composable
A function that only tidies up how a value is displayed does not need to be a composable.
export const formatCurrency = (value: number, symbol = '$'): string =>
symbol + value.toLocaleString('en-US')
Wrapping it as useFormatters() gains nothing. It holds no reactive state and depends on no Vue context, so importing it is enough. Whether to apply caching is a decision for the caller's computed (chapter 5).
Use the test from the opening to tell them apart. useCounter creates state, so it is the first kind. useEventListener holds no state but registers hooks, so it can only be called inside setup. useTicker holds no state either but hands cleanup to the scope, so it needs an active scope. Both are the second kind. formatCurrency is neither, so leave it as a plain function.
Summary
- The test for the
useprefix is whether it creates reactive state, or depends on the caller's context (a component or an effect scope). A formatting function that is neither is fine as a plain module - Return a plain object containing refs. When the internals use
reactive, put it throughtoRefs(). If you want property-style access, wrap it on the receiving side - Normalise arguments with
toValue(), and calltoValue()inside the watcher's callback. Extracting it outside loses changes to the argument MaybeRefOrGetteraccepts plain values too, so it cannot catch non-following calls at type-check time. Narrow arguments that must follow changes to() => T- Remove side effects inside the composable that registered them.
onScopeDispose()works outside components too, but deferring the start toonMountedgives that reach up. It is a choice between the two onScopeDisposeandonUnmountedrun in opposite orders, with dispose going from parent to child. Neither runs while a component sits in theKeepAlivecache- The rule is to call them synchronously inside
<script setup>orsetup(). Only<script setup>allows calls after anawait, where the compiler restores the context. They can also be called inside hooks, but anonMountedregistered insideonMountednever runs, and no warning is issued - Choose
shallowReffor data fetching, because deep reactivity is unnecessary. The casting difference only shows when you annotate the return type - Check
signal.abortedbefore writing. Without it, theAbortErrorfromabort()lands inerror.abort()sets the flag on the spot, so the same guard holds even on routes wheresignalnever arrives - Everything so far assumes browser-only execution. Rendering on the server adds the constraints in chapter 9
- Vue — Composables — conventions, the shape of return values, and where they can be called
- Vue — Server-Side Rendering — cross-request state pollution and platform-specific APIs
- Vue — Reactivity API: Advanced —
shallowRef/effectScope/onScopeDispose - Vue — Reactivity API: Utilities — the difference between
toValueandunref - React guide — Custom hooks — the same idea of extracting logic into a function, differing in the dependency array
What to read next
- Composables and server-side rendering — the three forms that break under SSR, and how to avoid them
- v-model between components — declaring parent-child two-way binding with
defineModel