Composables
Pull stateful logic out of components into useSomething() functions that accept refs or getters and clean up after themselves.
What you'll learn
When you finish, you can do these.- 1Write a composable that gives each caller its own state
- 2Accept a ref, a getter or a plain value with toValue()
- 3Clean up a composable's timer with onScopeDispose()
- 4Fix a composable that shares state or is called after setup
A shop site needs the same product search in two places. Each one waits until you stop typing, with a timer, then lists the products that match. Copy that code into both, and every fix must be made twice; sooner or later one copy forgets to stop its timer. A composable is a function that holds such logic and the values it keeps, its state, so any component gets it in one line.
The Code tab holds a small page: a search box in App.vue and, under it, a list of matches in ResultList.vue. The list gets its search from a composable, useSearch(), in useSearch.ts. By the end you can add a second list that searches on its own, and say why the two never mix. You need one idea from Reactivity in depth: a value pulled out of a reactive() object is a copy that never updates, while a ref stays live.
A function that owns state
This section shows the core of useSearch: every component that calls it gets state of its own. A composable is an ordinary function that uses Vue's reactivity tools, such as refs, and watchers that run code when a value changes. Its name starts with use, which tells readers to call it during setup, while a component's <script setup> runs.
useSearch takes the search text, query, and a delay, wait. It makes two refs, the matching products and a flag that is true while a search waits, plus a timer.
export function useSearch(query: MaybeRefOrGetter<string>, wait = 300) {
const results = ref<string[]>([])
const searching = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
// … a watcher fills results, shown in the next section
return { results, searching }
}
The refs are made inside the function, so each call makes new ones, like the local variables of any function: two components, two result lists.
State inside useSearch()const results = ref([]) runs on every call. Two lists, two arrays: typing in one search leaves the other alone.
State outside useSearch()At the top of the file, that line runs once, when the file is first imported. Every caller gets that same ref: one copy for the whole app.
Shared state is sometimes what you want, such as a cart count shown in three places. Choose it on purpose, with a Pinia store, Vue's official tool for app-wide state: on a server that renders pages, state at the top of a file is shared by every visitor.
useSearch returns an object of refs, not a reactive() object, so a caller can destructure it, pulling each ref out by name as ResultList.vue does, and each one stays live.
Not every helper needs to be a composable. formatPrice(cents), which turns 1250 into "€12.50", has no state and nothing to clean up, so it stays a plain function, free of the call-during-setup rule.
Accept a ref, a getter or a plain value
This section lets useSearch accept the search text in any form: a ref, a fixed string, or a prop, a value a parent component passes down. MaybeRefOrGetter<string> is the TypeScript type for "a string, a ref of a string, or a getter", a function that returns one. Inside, toValue() turns any of the three into the current string: it reads a ref, calls a getter, and passes a plain value through.
In useSearch.ts, a watcher reads the text through toValue() and filters the products once typing pauses for wait milliseconds:
watch(() => toValue(query), (text) => {
searching.value = true
clearTimeout(timer)
timer = setTimeout(() => {
const q = text.trim().toLowerCase()
results.value = products.filter(p => p.toLowerCase().includes(q))
searching.value = false
}, wait)
}, { immediate: true })
Each change restarts the timer, and immediate: true fills the list on load.
The getter, () => toValue(query), is what matters: the watcher runs it after every change, so it sees the new text. Call toValue(query) once at the top of the function instead, and you get the string as it was then, forever.
Props are the usual reason to pass a getter. App.vue passes the search text to ResultList.vue as a prop named term. There, props.term read on its own is a plain string, a copy, so the list passes a getter: useSearch(() => props.term).
Clean up with onScopeDispose()
This section makes useSearch tidy up: each search starts a timer, and the list may leave the page before it fires. onScopeDispose() registers a function that runs when the composable's owner, usually the calling component, goes away. In useSearch.ts, it clears the timer:
onScopeDispose(() => clearTimeout(timer))
A list removed mid-search leaves no timer behind.
In a component, its own clean-up hook, onUnmounted(), would run at the same moment. Prefer onScopeDispose() anyway: a composable can also run in a Pinia setup store (a store written with refs) or an effectScope(), Vue's container for watchers outside components. onScopeDispose() runs when that store or scope stops, while onUnmounted() knows only components.
Both work only if the composable is called during setup, because Vue links its watchers and cleanups to whatever is being set up at that moment. A top-level await in <script setup> is fine: the compiler restores that link after it. Code in a setTimeout callback, in a .then() callback, or after an await in your own async function runs once setup is over.
What goes wrong
Two mistakes are common: state shared by accident, and a composable called after setup. The first moves the state out of the function, often to "fix" a value that seemed to reset.
const results = ref<string[]>([])
const searching = ref(false)
export function useSearch(query: MaybeRefOrGetter<string>, wait = 300) {
Every list on the page now shows the same results, whichever search finished last, even under a heading for a different term. Keep per-caller state inside the function.
export function useSearch(query: MaybeRefOrGetter<string>, wait = 300) {
const results = ref<string[]>([])
const searching = ref(false)
Each call gets its own two refs again.
The second mistake calls the composable once some data has arrived. Picture the list starting from the shopper's last search term, loaded by an imagined function, loadSavedTerm():
loadSavedTerm().then((saved) => {
const { results } = useSearch(saved)
})
Vue warns: onScopeDispose() is called when there is no active effect scope to be associated with. The watcher inside belongs to no component, so it outlives the list, and the results it returns never reach the template. Call the composable during setup with a ref, and fill the ref later.
const term = ref('')
const { results } = useSearch(term)
loadSavedTerm().then((saved) => { term.value = saved })
The empty term matches every product until the saved term replaces it.
What to remember
- A composable is a function, named
use…, that creates reactive state and effects for the component that calls it. State made inside it belongs to each caller. - Return an object of refs so callers can destructure it. A helper with no state or cleanup is a plain function.
- Accept
MaybeRefOrGetterand read it withtoValue()inside a watcher or computed, so changes reach the composable. Pass props as getters. - Clean up with
onScopeDispose(). It runs when the owner stops: a component, a store or an effect scope. - Call composables during setup. A top-level
awaitis fine; a timer or a.then()callback is not.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, add
<ResultList term="cap" />under the first list. It shows Cap and keeps showing it while you type in the box: it has its own refs, andtoValue()passes the plain string through. - In useSearch.ts, move the
resultsandsearchinglines up to the Try it 2 comment, outside the function. On load the mug list now shows Cap, the search that finished last. Typetotein the box: both lists show Tote bag, even the one headed Results for "cap". Move the lines back to separate them again. - In ResultList.vue, change
useSearch(() => props.term)touseSearch(props.term)and type in the box. The heading follows your typing, because the template reads the prop, but the list never changes:useSearchreceived the string "mug" once. Put the getter back and the list follows again.