Accepting Reactive Arguments
Let an argument be a value, a ref or a getter, read it with toValue, and read it inside an effect so it tracks.
function toValue<T>(source: T | Ref<T> | (() => T)): TA composable that takes a plain value is only useful until the value changes. useFilteredItems(items, 'red') filters once; what you want is a result that follows a search box. The fix is to accept a value, a ref, or a getter, and to read the argument with toValue.
toValue
toValue(source), added in Vue 3.3, returns the ref's value if given a ref, calls the function and returns its result if given a function, and returns anything else unchanged. The parameter type for that is MaybeRefOrGetter<T>, which is exactly T | Ref<T> | (() => T). It is unref plus getter support; unref would hand you the function itself.
Where you call it decides whether it reacts
Reading toValue(query) once at the top of the composable produces a snapshot that never updates. Read it inside a computed, a watchEffect, or a watch getter instead. There the read is tracked as a dependency, so the effect re-runs when the ref changes or when the getter's own dependencies do.
What trips people up
A prop cannot be passed as props.id and stay reactive, because that expression is a number, not a ref. Pass () => props.id. For the same reason a watch source inside a composable should be () => toValue(arg), not arg, which only works when the caller happened to pass a ref.
Example
<script setup lang="ts">
import { ref } from 'vue'
import { useFilteredItems } from '../composables/useFilteredItems'
const props = defineProps<{ items: string[] }>()
const query = ref('')
const { results } = useFilteredItems(() => props.items, query)
</script>
<template>
<label>
Filter
<input v-model="query" type="search" />
</label>
<ul>
<li v-for="item in results" :key="item">{{ item }}</li>
</ul>
</template>
<style scoped>
input {
margin-left: 0.5rem;
}
</style>When to use it
- Write a composable that works whether the caller passes a constant, a ref or `() => props.id`.
- Keep a filtered or sorted list in step with a search input without the component wiring a watcher.
- Re-run a composable's effect when a prop changes, by accepting a getter over that prop.
More examples
The composable, taking either form
Both reads happen inside the computed, so the result tracks the ref the component passed and the prop the getter reads. The same function also accepts two plain values, which is what makes it easy to call from a test.
// src/composables/useFilteredItems.ts
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
export function useFilteredItems(
items: MaybeRefOrGetter<string[]>,
query: MaybeRefOrGetter<string>
) {
const results = computed(() => {
const term = toValue(query).trim().toLowerCase()
const list = toValue(items)
if (term === '') return list
return list.filter((item) => item.toLowerCase().includes(term))
})
return { results }
}
console.log(useFilteredItems(['Red', 'Blue'], 'bl').results.value)
// Output: [ 'Blue' ]The snapshot mistake
Both lines compile and both look reactive. The first captures the value at call time and silently stops updating; the second re-reads on each evaluation and tracks whatever `toValue` touched. When a composable mysteriously shows the first value forever, this is the line to look for.
// src/composables/useFilteredItems.ts
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
export function useFilteredItems(query: MaybeRefOrGetter<string>) {
// Wrong: read once, outside any effect. This string never changes again.
const term = toValue(query)
const stale = computed(() => term.toUpperCase())
// Right: the read happens on every evaluation of the computed.
const live = computed(() => toValue(query).toUpperCase())
return { stale, live }
}Reacting with watch instead of computed
Use `watch` when the argument changing should cause an action rather than produce a value. The source is a getter wrapping `toValue`, which normalises all three argument forms — passing `query` directly only works when the caller handed over a ref or a getter, since those are valid watch sources; a plain string is not, so Vue warns in development and the watcher never fires.
// src/composables/useQueryHistory.ts
import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
export function useQueryHistory(query: MaybeRefOrGetter<string>) {
const history = ref<string[]>([])
watch(
() => toValue(query),
(value) => {
if (value !== '') history.value.push(value)
},
{ immediate: true }
)
return { history }
}
Discussion