Skip to content

Vue 3.5's onWatcherCleanup: Mastering Side Effect Management in Vue Applications

Updated: at 

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:

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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
ref
<any | null>(null)
let let controller: AbortController | nullcontroller: 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: stringnewId: string) => { if (let controller: AbortController | nullcontroller) { let controller: AbortControllercontroller.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 | nullcontroller = 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: Responseresponse = 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: stringnewId}`, {
RequestInit.signal?: AbortSignal | null | undefined
An AbortSignal to set request's signal.
signal
: let controller: AbortControllercontroller.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: Responseresponse.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: anyvalue = await const response: Responseresponse.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)
json
()
} catch (function (local var) error: unknownerror) { if (function (local var) error: unknownerror instanceof var Error: ErrorConstructorError && function (local var) error: Errorerror.Error.name: stringname !== '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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
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.
@sincev0.1.100
error
('Fetch error:', function (local var) error: Errorerror)
const userData: Ref<any, any>userData.Ref<any, any>.value: anyvalue = null } } }) </script> <template> <div: HTMLAttributes & ReservedPropsdiv> <input: InputHTMLAttributes & ReservedPropsinput v-model="const userId: Ref<string, string>userId" InputHTMLAttributes.placeholder?: string | undefinedplaceholder="Enter user ID" /> <div: HTMLAttributes & ReservedPropsdiv v-if="const userData: Ref<any, any>userData"> <h2: HTMLAttributes & ReservedPropsh2>User Data</h2: HTMLAttributes & ReservedPropsh2> <pre: HTMLAttributes & ReservedPropspre>{{ 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.
@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer An array of strings and numbers that acts as an approved list for selecting the object properties that will be stringified.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
stringify
(const userData: Ref<any, any>userData, null, 2) }}</pre: HTMLAttributes & ReservedPropspre>
</div: HTMLAttributes & ReservedPropsdiv> <div: HTMLAttributes & ReservedPropsdiv v-else-if="const userId: Ref<string, string>userId && !const userData: Ref<any, any>userData"> User not found </div: HTMLAttributes & ReservedPropsdiv> </div: HTMLAttributes & ReservedPropsdiv> </template>

Problems with this method:

  1. External controller management
  2. Manual request abortion
  3. Cleanup logic separate from effect
  4. 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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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.
@paramcleanupFn - The callback function to attach to the effect's cleanup.@paramfailSilently - if `true`, will not throw warning when called without an active effect.@paramowner - The effect that this cleanup function should be attached to. By default, the current active effect.
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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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.
@paramvalue - The object to wrap in the ref.@see{@link https://vuejs.org/api/reactivity-core.html#ref}
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: stringnewId: string) => { const const controller: AbortControllercontroller = 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.
@paramcleanupFn - The callback function to attach to the effect's cleanup.@paramfailSilently - if `true`, will not throw warning when called without an active effect.@paramowner - The effect that this cleanup function should be attached to. By default, the current active effect.
onWatcherCleanup
(() => {
const controller: AbortControllercontroller.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: Responseresponse = 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: stringnewId}`, {
RequestInit.signal?: AbortSignal | null | undefined
An AbortSignal to set request's signal.
signal
: const controller: AbortControllercontroller.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: Responseresponse.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: anyvalue = await const response: Responseresponse.Body.json(): Promise<any>
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json)
json
()
} catch (function (local var) error: unknownerror) { if (function (local var) error: unknownerror instanceof var Error: ErrorConstructorError && function (local var) error: Errorerror.Error.name: stringname !== '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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
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.
@sincev0.1.100
error
('Fetch error:', function (local var) error: Errorerror)
const userData: Ref<any, any>userData.Ref<any, any>.value: anyvalue = null } } }) </script> <template> <div: HTMLAttributes & ReservedPropsdiv> <input: InputHTMLAttributes & ReservedPropsinput v-model="const userId: Ref<string, string>userId" InputHTMLAttributes.placeholder?: string | undefinedplaceholder="Enter user ID" /> <div: HTMLAttributes & ReservedPropsdiv v-if="const userData: Ref<any, any>userData"> <h2: HTMLAttributes & ReservedPropsh2>User Data</h2: HTMLAttributes & ReservedPropsh2> <pre: HTMLAttributes & ReservedPropspre>{{ 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.
@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer An array of strings and numbers that acts as an approved list for selecting the object properties that will be stringified.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
stringify
(const userData: Ref<any, any>userData, null, 2) }}</pre: HTMLAttributes & ReservedPropspre>
</div: HTMLAttributes & ReservedPropsdiv> <div: HTMLAttributes & ReservedPropsdiv v-else-if="const userId: Ref<string, string>userId && !const userData: Ref<any, any>userData"> User not found </div: HTMLAttributes & ReservedPropsdiv> </div: HTMLAttributes & ReservedPropsdiv> </template>

Benefits of onWatcherCleanup

  1. Clearer code: Cleanup logic is right next to the effect
  2. Automatic execution
  3. Fewer memory leaks
  4. Simpler logic
  5. Consistent with Vue API
  6. Fits seamlessly into Vue’s reactivity system

When to Use onWatcherCleanup

Use it to:

Advanced Techniques

Multiple Cleanups

watch(dependency, () => {
  const const timer1: NodeJS.Timeouttimer1 = 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()`.
@sincev0.0.1@paramcallback The function to call when the timer elapses.@paramdelay The number of milliseconds to wait before calling the `callback`.@paramargs Optional arguments to pass when the `callback` is called.@returnfor use with {@link clearInterval}
setInterval
(() => { /* ... */ }, 1000)
const const timer2: NodeJS.Timeouttimer2 = 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()`.
@sincev0.0.1@paramcallback The function to call when the timer elapses.@paramdelay The number of milliseconds to wait before calling the `callback`.@paramargs Optional arguments to pass when the `callback` is called.@returnfor use with {@link clearInterval}
setInterval
(() => { /* ... */ }, 5000)
onWatcherCleanup(() => function clearInterval(intervalId: NodeJS.Timeout | string | number | undefined): void (+1 overload)
Cancels a `Timeout` object created by `setInterval()`.
@sincev0.0.1@paramtimeout A `Timeout` object as returned by {@link setInterval} or the `primitive` of the `Timeout` object as a string or a number.
clearInterval
(const timer1: NodeJS.Timeouttimer1))
onWatcherCleanup(() => function clearInterval(intervalId: NodeJS.Timeout | string | number | undefined): void (+1 overload)
Cancels a `Timeout` object created by `setInterval()`.
@sincev0.0.1@paramtimeout A `Timeout` object as returned by {@link setInterval} or the `primitive` of the `Timeout` object as a string or a number.
clearInterval
(const timer2: NodeJS.Timeouttimer2))
// More logic... })

Conditional Cleanup

watch(dependency, () => {
  if (condition) {
    const const resource: anyresource = acquireResource()
    onWatcherCleanup(() => releaseResource(const resource: anyresource))
  }

  // More code...
})

With watchEffect

watchEffect((onCleanup: anyonCleanup) => {
  const const data: anydata = fetchSomeData()

  onCleanup: anyonCleanup(() => {
    cleanupData(const data: anydata)
  })
})

How onWatcherCleanup Works

Image description

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:

  1. Before the effect re-runs
  2. 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:

These design choices contribute to the overall performance of your Vue applications, especially when dealing with many watchers and side effects.

Best Practices

  1. Register cleanups early in your effect function
  2. Keep cleanup functions simple and focused
  3. Avoid creating new side effects within cleanup functions
  4. Handle potential errors in your cleanup logic
  5. 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.

Related Posts