Introduction
My team and I recently discussed Vue 3.5’s new features, focusing on the onWatcherCleanup
function. I found it so interesting that I decided to write this blog post to share insights.
The Side Effect Challenge in Vue
Managing side effects in Vue can be challenging, especially when dealing with:
- API calls
- Timer operations
- Event listener management
These side effects become particularly tricky when values change rapidly.
A Common Use Case: Fetching User Data
To illustrate the power of onWatcherCleanup
, let’s compare the old and new ways of fetching user data.
The Old Way
<script setup lang="ts">
import { function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref, function watch<T, Immediate extends Readonly<boolean> = false>(source: WatchSource<T>, cb: WatchCallback<T, MaybeUndefined<T, Immediate>>, options?: WatchOptions<Immediate>): WatchHandle (+3 overloads)
watch } from 'vue'
const const userId: Ref<string, string>
userId = ref<string>(value: string): Ref<string, string> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref<string>('')
const const userData: Ref<any, any>
userData = ref<any>(value: any): Ref<any, any> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref<any | null>(null)
let let controller: AbortController | null
controller: AbortController | null = null
watch<string, false>(source: WatchSource<string>, cb: WatchCallback<string, string>, options?: WatchOptions<false> | undefined): WatchHandle (+3 overloads)
watch(const userId: Ref<string, string>
userId, async (newId: string
newId: string) => {
if (let controller: AbortController | null
controller) {
let controller: AbortController
controller.AbortController.abort(reason?: any): void (+1 overload)
Invoking this method will set this object's AbortSignal's aborted flag and signal to any observers that the associated activity is to be aborted.abort()
}
let controller: AbortController | null
controller = new var AbortController: new () => AbortController
A controller object that allows you to abort one or more DOM requests as and when desired.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)
A controller object that allows you to abort one or more DOM requests as and when desired.AbortController()
try {
const const response: Response
response = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch)fetch(`https://api.example.com/users/${newId: string
newId}`, {
RequestInit.signal?: AbortSignal | null | undefined
An AbortSignal to set request's signal.signal: let controller: AbortController
controller.AbortController.signal: AbortSignal
Returns the AbortSignal object associated with this object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal)
Returns the AbortSignal object associated with this object.signal
})
if (!const response: Response
response.Response.ok: boolean
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok)ok) {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('User not found')
}
const userData: Ref<any, any>
userData.Ref<any, any>.value: any
value = await const response: Response
response.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)json()
} catch (function (local var) error: unknown
error) {
if (function (local var) error: unknown
error instanceof var Error: ErrorConstructor
Error && function (local var) error: Error
error.Error.name: string
name !== 'AbortError') {
var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without calling `require('console')`.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stderr` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr
```
If formatting elements (e.g. `%d`) are not found in the first string then
[`util.inspect()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options) is called on each argument and the
resulting string values are concatenated. See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)
for more information.error('Fetch error:', function (local var) error: Error
error)
const userData: Ref<any, any>
userData.Ref<any, any>.value: any
value = null
}
}
})
</script>
<template>
<div: HTMLAttributes & ReservedProps
div>
<input: InputHTMLAttributes & ReservedProps
input v-model="const userId: Ref<string, string>
userId" InputHTMLAttributes.placeholder?: string | undefined
placeholder="Enter user ID" />
<div: HTMLAttributes & ReservedProps
div v-if="const userData: Ref<any, any>
userData">
<h2: HTMLAttributes & ReservedProps
h2>User Data</h2: HTMLAttributes & ReservedProps
h2>
<pre: HTMLAttributes & ReservedProps
pre>{{ var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.JSON.JSON.stringify(value: any, replacer?: (number | string)[] | null, space?: string | number): string (+1 overload)
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.stringify(const userData: Ref<any, any>
userData, null, 2) }}</pre: HTMLAttributes & ReservedProps
pre>
</div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-else-if="const userId: Ref<string, string>
userId && !const userData: Ref<any, any>
userData">
User not found
</div: HTMLAttributes & ReservedProps
div>
</div: HTMLAttributes & ReservedProps
div>
</template>
Problems with this method:
- External controller management
- Manual request abortion
- Cleanup logic separate from effect
- Easy to forget proper cleanup
The New Way: onWatcherCleanup
Here’s how onWatcherCleanup
improves the process:
<script setup lang="ts">
import { function ref<T>(value: T): [T] extends [Ref] ? IfAny<T, Ref<T>, T> : Ref<UnwrapRef<T>, UnwrapRef<T> | T> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref, function watch<T, Immediate extends Readonly<boolean> = false>(source: WatchSource<T>, cb: WatchCallback<T, MaybeUndefined<T, Immediate>>, options?: WatchOptions<Immediate>): WatchHandle (+3 overloads)
watch, function onWatcherCleanup(cleanupFn: () => void, failSilently?: boolean, owner?: ReactiveEffect | undefined): void
Registers a cleanup callback on the current active effect. This
registered cleanup callback will be invoked right before the
associated effect re-runs.onWatcherCleanup } from 'vue'
const const userId: Ref<string, string>
userId = ref<string>(value: string): Ref<string, string> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref<string>('')
const const userData: Ref<any, any>
userData = ref<any>(value: any): Ref<any, any> (+1 overload)
Takes an inner value and returns a reactive and mutable ref object, which
has a single property `.value` that points to the inner value.ref<any | null>(null)
watch<string, false>(source: WatchSource<string>, cb: WatchCallback<string, string>, options?: WatchOptions<false> | undefined): WatchHandle (+3 overloads)
watch(const userId: Ref<string, string>
userId, async (newId: string
newId: string) => {
const const controller: AbortController
controller = new var AbortController: new () => AbortController
A controller object that allows you to abort one or more DOM requests as and when desired.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController)
A controller object that allows you to abort one or more DOM requests as and when desired.AbortController()
function onWatcherCleanup(cleanupFn: () => void, failSilently?: boolean, owner?: ReactiveEffect | undefined): void
Registers a cleanup callback on the current active effect. This
registered cleanup callback will be invoked right before the
associated effect re-runs.onWatcherCleanup(() => {
const controller: AbortController
controller.AbortController.abort(reason?: any): void (+1 overload)
Invoking this method will set this object's AbortSignal's aborted flag and signal to any observers that the associated activity is to be aborted.abort()
})
try {
const const response: Response
response = await function fetch(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response> (+1 overload)
[MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch)fetch(`https://api.example.com/users/${newId: string
newId}`, {
RequestInit.signal?: AbortSignal | null | undefined
An AbortSignal to set request's signal.signal: const controller: AbortController
controller.AbortController.signal: AbortSignal
Returns the AbortSignal object associated with this object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/signal)
Returns the AbortSignal object associated with this object.signal
})
if (!const response: Response
response.Response.ok: boolean
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/ok)ok) {
throw new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('User not found')
}
const userData: Ref<any, any>
userData.Ref<any, any>.value: any
value = await const response: Response
response.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)json()
} catch (function (local var) error: unknown
error) {
if (function (local var) error: unknown
error instanceof var Error: ErrorConstructor
Error && function (local var) error: Error
error.Error.name: string
name !== 'AbortError') {
var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without calling `require('console')`.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stderr` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)).
```js
const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr
```
If formatting elements (e.g. `%d`) are not found in the first string then
[`util.inspect()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilinspectobject-options) is called on each argument and the
resulting string values are concatenated. See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)
for more information.error('Fetch error:', function (local var) error: Error
error)
const userData: Ref<any, any>
userData.Ref<any, any>.value: any
value = null
}
}
})
</script>
<template>
<div: HTMLAttributes & ReservedProps
div>
<input: InputHTMLAttributes & ReservedProps
input v-model="const userId: Ref<string, string>
userId" InputHTMLAttributes.placeholder?: string | undefined
placeholder="Enter user ID" />
<div: HTMLAttributes & ReservedProps
div v-if="const userData: Ref<any, any>
userData">
<h2: HTMLAttributes & ReservedProps
h2>User Data</h2: HTMLAttributes & ReservedProps
h2>
<pre: HTMLAttributes & ReservedProps
pre>{{ var JSON: JSON
An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.JSON.JSON.stringify(value: any, replacer?: (number | string)[] | null, space?: string | number): string (+1 overload)
Converts a JavaScript value to a JavaScript Object Notation (JSON) string.stringify(const userData: Ref<any, any>
userData, null, 2) }}</pre: HTMLAttributes & ReservedProps
pre>
</div: HTMLAttributes & ReservedProps
div>
<div: HTMLAttributes & ReservedProps
div v-else-if="const userId: Ref<string, string>
userId && !const userData: Ref<any, any>
userData">
User not found
</div: HTMLAttributes & ReservedProps
div>
</div: HTMLAttributes & ReservedProps
div>
</template>
Benefits of onWatcherCleanup
- Clearer code: Cleanup logic is right next to the effect
- Automatic execution
- Fewer memory leaks
- Simpler logic
- Consistent with Vue API
- Fits seamlessly into Vue’s reactivity system
When to Use onWatcherCleanup
Use it to:
- Cancel API requests
- Clear timers
- Remove event listeners
- Free resources
Advanced Techniques
Multiple Cleanups
watch(dependency, () => {
const const timer1: NodeJS.Timeout
timer1 = function setInterval<[]>(callback: () => void, ms?: number): NodeJS.Timeout (+2 overloads)
Schedules repeated execution of `callback` every `delay` milliseconds.
When `delay` is larger than `2147483647` or less than `1`, the `delay` will be
set to `1`. Non-integer delays are truncated to an integer.
If `callback` is not a function, a `TypeError` will be thrown.
This method has a custom variant for promises that is available using `timersPromises.setInterval()`.setInterval(() => { /* ... */ }, 1000)
const const timer2: NodeJS.Timeout
timer2 = function setInterval<[]>(callback: () => void, ms?: number): NodeJS.Timeout (+2 overloads)
Schedules repeated execution of `callback` every `delay` milliseconds.
When `delay` is larger than `2147483647` or less than `1`, the `delay` will be
set to `1`. Non-integer delays are truncated to an integer.
If `callback` is not a function, a `TypeError` will be thrown.
This method has a custom variant for promises that is available using `timersPromises.setInterval()`.setInterval(() => { /* ... */ }, 5000)
onWatcherCleanup(() => function clearInterval(intervalId: NodeJS.Timeout | string | number | undefined): void (+1 overload)
Cancels a `Timeout` object created by `setInterval()`.clearInterval(const timer1: NodeJS.Timeout
timer1))
onWatcherCleanup(() => function clearInterval(intervalId: NodeJS.Timeout | string | number | undefined): void (+1 overload)
Cancels a `Timeout` object created by `setInterval()`.clearInterval(const timer2: NodeJS.Timeout
timer2))
// More logic...
})
Conditional Cleanup
watch(dependency, () => {
if (condition) {
const const resource: any
resource = acquireResource()
onWatcherCleanup(() => releaseResource(const resource: any
resource))
}
// More code...
})
With watchEffect
watchEffect((onCleanup: any
onCleanup) => {
const const data: any
data = fetchSomeData()
onCleanup: any
onCleanup(() => {
cleanupData(const data: any
data)
})
})
How onWatcherCleanup Works
Vue uses a WeakMap to manage cleanup functions efficiently. This approach ensures that cleanup functions are properly associated with their effects and are executed at the right time.
Executing Cleanup Functions
Cleanup functions are executed in two scenarios:
- Before the effect re-runs
- When the watcher stops
This ensures that resources are properly managed and side effects are cleaned up at the appropriate times.
Under the Hood
While we won’t delve too deep into the implementation details, it’s worth noting that onWatcherCleanup
is tightly integrated with Vue’s reactivity system. It uses the current active watcher to associate cleanup functions with the correct effect, ensuring that cleanups are executed in the right context.
Performance
onWatcherCleanup
is designed with efficiency in mind:
- Cleanup arrays are created only when needed
- The use of WeakMap helps with memory management
- Adding cleanup functions is a quick operation
These design choices contribute to the overall performance of your Vue applications, especially when dealing with many watchers and side effects.
Best Practices
- Register cleanups early in your effect function
- Keep cleanup functions simple and focused
- Avoid creating new side effects within cleanup functions
- Handle potential errors in your cleanup logic
- Thoroughly test your effects and their associated cleanups
Conclusion
Vue 3.5’s onWatcherCleanup
is a powerful addition to the framework’s toolset for managing side effects. It allows us to write cleaner, more maintainable code by keeping setup and teardown logic together. By leveraging this feature, we can create more robust applications that efficiently handle resource management and avoid common pitfalls associated with side effects.
As you start using onWatcherCleanup
in your projects, you’ll likely find that it simplifies many common patterns and helps prevent subtle bugs related to unmanaged side effects.