Write a composable like VueUse
Give your own composables the VueUse shape, with flexible inputs, an options object, refs and controls out, a browser guard and quiet cleanup.
What you'll learn
When you finish, you can do these.- 1Write a composable: input first, options last, return refs
- 2Read a changing input with toValue() where it is used
- 3Keep timers off the server and stop them on unmount
- 4Spot and fix a bar that stays full and a frozen countdown
Picture a countdown timer on a page: the seconds left, a progress bar and a Pause button. Its logic belongs in a composable: a function that keeps refs (boxes that hold a value and tell Vue when it changes) together with the code that updates them. Written without a plan, such a function often takes a row of unnamed settings, misses a new number of seconds, or keeps ticking after the timer has left the page.
VueUse, a library of more than 200 composables, avoids all three with a few shared habits. The Code tab holds the countdown written with them: useCountdown() in useCountdown.ts; Timer.vue, which shows it; and App.vue, the page around it. You need one idea from Async state and network, where VueUse's useAsyncState() loaded quotes: it takes the function it runs first and its options in one object last, such as { immediate: false }, and hands back refs. By the end you can give your own composables that shape.
The shape VueUse composables share
The first habit is the outline: what goes in and what comes back. Here it is in useCountdown.ts, with the body left out:
export interface UseCountdownOptions {
/** Called after every step */
onTick?: () => void
}
export function useCountdown(seconds: MaybeRefOrGetter<number>, options: UseCountdownOptions = {}) {
const { onTick } = options
// …
return { remaining: readonly(remaining), progress, isActive: readonly(isActive), pause, resume, reset }
}
The main input comes first: seconds, the number to count down from. Its type, Vue's MaybeRefOrGetter<number>, accepts a plain number, a ref, or a getter (a function that returns the number), so the caller passes whichever it has.
Everything optional goes in one object at the end, described for TypeScript by the UseCountdownOptions interface. A caller writes useCountdown(10, { onTick }): each setting is named in the call, and adding an option next year breaks nobody. Compare a bare useCountdown(10, false): nothing in it says what false turns off.
The result is an object of refs, so the caller can destructure it, taking each ref out by name, and every value stays live. Vue's readonly() around remaining (the seconds left) and isActive (true while it counts) lets the caller read them while only the composable changes them. Work that runs over time also hands back pause() and resume(), which with isActive make the trio VueUse calls Pausable.
In the preview, Timer.vue shows 10 s left, counting down, and a bar that empties as it goes. Click Pause: the count stops and the button reads Resume. Around the timer, App.vue has a Seconds box, a Hide timer button and a Ticks so far line that counts every step.
Read the input where it is used
The second habit reads the input each time the number is needed, because a getter's value can change after useCountdown() runs.
Timer.vue passes such a getter: it calls useCountdown(() => props.seconds, …). The getter reads Timer's seconds prop, a value App.vue hands down to it: the number in the Seconds box.
Inside the composable, Vue's toValue() returns the current value of a number, a ref or a getter. Call it where the value is used: inside a computed (a ref whose value Vue works out from other values), a watcher (code Vue runs when a value changes), or a function such as reset() that runs later. The progress bar's value in useCountdown.ts is such a computed:
const progress = computed(() => remaining.value / toValue(seconds))
It divides the seconds left by the caller's seconds: 1 when the timer starts, 0.5 halfway. Vue notes both reads and caches the result until one of them changes. The diagram draws the same shape for a shop cart: a price and a quantity feed a cached total, as remaining and seconds feed progress.
Change Seconds in the preview and the bar moves at once, because the computed reads the prop through the getter. The count does not start over, because nothing watches seconds yet; Try it 2 adds that watcher.
Guard the browser, and clean up quietly
The last two habits keep a timer from running where nobody can stop it: on a server, and after its component is gone.
Some apps, such as Nuxt apps, first run components on a server to build the page's HTML. No component is unmounted (removed from the page) there, so a timer started on the server never stops. isClient, a flag from VueUse, is true only in the browser, so check it before anything browser-only. resume() in useCountdown.ts checks it first:
if (!isClient || isActive.value || remaining.value <= 0) return
On the server, resume() returns at once. In the browser, the same line also refuses a second timer, or one with no seconds left.
For cleanup, VueUse uses tryOnScopeDispose(), its version of Vue's onScopeDispose(). Both register a function to run when the component that called the composable unmounts. useCountdown.ts hands it pause:
tryOnScopeDispose(pause)
Click Hide timer in the preview: Timer.vue unmounts, pause() clears the timer, and Ticks so far stops climbing.
The two differ outside a scope. A scope owns the watchers and computed values made during setup, and every component has one; a test that calls the composable directly has none. There onScopeDispose() warns, and tryOnScopeDispose() returns false and does nothing.
Quiet suits tests, but a composable called from a click handler registers no cleanup, and nothing tells you, so test that yours runs.
What goes wrong
The first mistake reads the input once, at the top of useCountdown(), instead of inside the computed:
const total = toValue(seconds)
const progress = computed(() => remaining.value / total)
Change Seconds from 10 to 20, then click Reset and Resume. The timer counts down from 20, but the bar stays full until it passes 10, because total is still the 10 it read at the start. Read the input inside the computed:
const progress = computed(() => remaining.value / toValue(seconds))
With the fix, the bar starts full at 20 and is half empty at 10.
The second mistake returns one reactive() object, an object whose properties Vue tracks, instead of refs:
const state = reactive({ remaining: toValue(seconds), isActive: false })
// … the timer changes state.remaining …
return state
Timer.vue destructures it, const { remaining } = useCountdown(…), and the page stays at 10 s left while Ticks so far keeps rising. Destructuring a reactive object copies the numbers out of it, and the copies never change. Return refs, as VueUse does:
return { remaining: readonly(remaining), progress, isActive: readonly(isActive), pause, resume, reset }
A ref taken out by name is still the same ref, so the seconds left count down again.
What to remember
- Take the main input first, as
MaybeRefOrGetter, and everything optional in one options object last. - Return an object of refs, read-only where only the composable writes, plus
pause(),resume()andisActivefor work that runs over time. - Read the input with
toValue()inside a computed, a watcher or a function that runs later, never once at the top. - Check
isClientbefore starting a timer or touching anything browser-only. - Clean up with
tryOnScopeDispose(), and test that it runs: outside a scope it is skipped without a word.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add an
immediateoption. In useCountdown.ts, addimmediate?: booleanto the interface, change the destructuring toconst { onTick, immediate = true } = options, and replaceresume()withif (immediate) resume(). In Timer.vue, passimmediate: false: the timer waits at 10 s left until you click Resume. - Make the timer start over when Seconds changes. In useCountdown.ts, import
watchand addwatch(() => toValue(seconds), () => { reset(); resume() }). Change Seconds to 5: the timer jumps to 5 s left and counts down again. - Delete the
tryOnScopeDispose(pause)line. Hide the timer: Ticks so far keeps climbing until the hidden countdown reaches 0. Show and hide it again quickly, and for a while it climbs by two each second. Put the line back, and hiding stops the count.