Meet VueUse
Use ready-made composables for browser features, and let useEventListener() add and remove listeners for you.
What you'll learn
When you finish, you can do these.- 1Install VueUse and show the mouse position with useMouse()
- 2Let useEventListener() add and remove a listener for you
- 3Call VueUse functions while script setup runs
- 4Spot and fix a listener that outlives its component
Picture a page with a mouse readout: a line such as "Mouse: 240, 130" that follows the pointer. A Hide readout button takes it off the page. Built by hand, the readout needs three steps. First, a ref, a box that holds a value and tells Vue when it changes, keeps the position. Second, a listener, a function the browser calls on every move, is added when the readout mounts (Vue puts it on the page). Third, that listener is removed when the readout unmounts (Vue takes it off). Skip the third and the listener runs on after the readout is gone, with no warning.
VueUse is a library of more than 200 composables: ready-made functions that do those steps for you and hand you refs. In the Code tab, MouseReadout.vue is the readout built with VueUse, and App.vue is the page around it. By the end you can read every line and add another VueUse function. You need one idea from Reactivity basics and its click counter: when a ref's value changes, Vue updates the page that shows it.
Install it and read a docs page
VueUse is one package, and its useMouse() function puts the mouse position on the page. Add the package to a project with npm, the tool that installs JavaScript packages:
npm install @vueuse/core
The Code tab has it already. This track uses VueUse 15, which needs Vue 3.5 and Node 22 or newer (Node runs JavaScript outside the browser).
Here is MouseReadout.vue from the Code tab with only the mouse part. It imports useMouse() and prints what it returns:
<script setup lang="ts">
import { useMouse } from '@vueuse/core'
const { x, y } = useMouse()
</script>
<template>
<p>Mouse: {{ x }}, {{ y }}</p>
</template>
The page shows "Mouse: 240, 130", and the numbers follow the pointer. useMouse() returns an object of refs, so you destructure it, taking x and y out by name, and use them like refs you make with ref().
Most VueUse functions work this way: call them in <script setup>, get refs back. App.vue calls a second one, useWindowSize(), and prints the preview's width in pixels.
Every function has a page on vueuse.org with its name in the address: a live demo, then usage examples, then Type Declarations near the bottom. Read that section before you guess an option, a setting you can pass in: it lists each one with its default and the exact shape of what comes back.
useEventListener does the three steps
useMouse() is built on a smaller function you can call yourself: useEventListener(). It takes the name of an event, such as a move or a click, and a handler, your function to run each time it happens. It adds the handler as a listener at once and removes it when the component that called it unmounts. Unless you pass an element first, it listens on window, the browser page itself.
The full MouseReadout.vue in the Code tab uses it to count how often the handler runs. The count, moves, is a ref in its own file, moves.ts, so App.vue can still print it after the readout is gone:
<script setup lang="ts">
import { useMouse, useEventListener } from '@vueuse/core'
import { moves } from './moves'
const { x, y } = useMouse()
useEventListener('pointermove', () => {
moves.value++
})
</script>
A pointermove event fires when a mouse, a finger or a pen moves. On the page, the number in "Pointer handler runs: 0" climbs while you move.
Every component goes through the same steps, called its lifecycle. The listener starts at the first, while <script setup> runs, and VueUse removes it at the last, when the component unmounts.
setup()script setup runs, no DOM yetonBeforeMountonMountedthe DOM exists, template refs are setonUpdatedafter each re-renderonUnmountedclean up timers and listeners
Click Hide readout in the preview and move the pointer. The count stands still, because the listener left with the component.
Call it while the component is set up
The cleanup works only when useEventListener() is called at the right moment. VueUse ties the listener to the component being set up when you call it, while its <script setup> runs from top to bottom. So the call goes straight in <script setup>, not in code that runs later: a click handler, a function that setTimeout runs after a delay, or a function of yours after an await, where it pauses to wait for something slow.
Called anywhere else, the listener has no owner. VueUse looks for one, finds none and skips the cleanup without a warning, so the leak goes unnoticed.
What goes wrong
The first mistake is to start tracking only when the user asks. Picture a Start button in the readout that calls startTracking(), a function that adds the listener:
function startTracking() {
useEventListener('pointermove', () => {
moves.value++
})
}
After one click on Start, the count rises as you move. Click Start again and every move counts twice. Hide the readout and the count keeps climbing. Each click added a listener that belongs to nothing. Call useEventListener() once during setup, and let a ref decide whether the handler does anything:
const tracking = ref(false)
useEventListener('pointermove', () => {
if (tracking.value) moves.value++
})
The Start button only has to set tracking.value = true. Each move counts once, and hiding the readout stops the count.
The second mistake hides in a listener written by hand, without VueUse. Vue's onMounted() and onUnmounted() run a function of yours when the component mounts and when it unmounts:
onMounted(() => {
window.addEventListener('pointermove', () => moves.value++)
})
onUnmounted(() => {
window.removeEventListener('pointermove', () => moves.value++)
})
It reads as correct, yet the count still climbs after the readout is hidden. removeEventListener() removes only the exact function that was added, and each arrow function here, written with =>, is a new one. By hand, the fix is to keep the handler in a variable and pass that variable to both calls. With VueUse there is nothing to match:
useEventListener('pointermove', () => moves.value++)
One call adds the listener, and that same listener leaves when the readout unmounts.
What to remember
- VueUse functions such as
useMouse()hand you refs that follow the browser. useEventListener()adds a listener and removes it when the calling component unmounts.useMouse()is built on it.- Call VueUse functions while
<script setup>runs. Called later, in a click handler or a timer, they leak without any warning. removeEventListener()removes only the exact function that was added, so a cleanup with a fresh arrow function removes nothing.- The Type Declarations section of a docs page lists every option and its default.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In MouseReadout.vue, add
const { pressed } = useMousePressed()(import it from@vueuse/core) and<p>{{ pressed ? 'Pressed' : 'Released' }}</p>. Hold the mouse button down inside the preview, or press with a finger: the label says Pressed until you let go. - In App.vue, import
useEventListenerand count clicks anywhere in the preview: addconst clicks = ref(0),useEventListener('click', () => clicks.value++)and<p>Clicks: {{ clicks }}</p>. Clicking empty space raises the number, and so does clicking the Hide readout button, because the listener sits onwindow. - In MouseReadout.vue, wrap the
useEventListener(…)call insetTimeout(() => { … }, 0). Hide the readout and move the pointer: the count keeps rising, and the preview shows no warning. Click Show readout, then Hide readout again, and every move now adds 2. Remove thesetTimeoutand hiding stops the count again.