Subscribe on changes!

watchEffect should allow multiple onInvalidate calls

avatar
Mar 2nd 2021

What problem does this feature solve?

Right now each effect 'remembers' only the last callback passed to onInvalidate:

watchEffect((onInvalidate) => {
  // do some work and then...
  onInvalidate(() => console.log('foo'))
  onInvalidate(() => console.log('bar'))
})

the first console.log won't ever be called. While in this example it's trivial to shove both console.logs to a single cleanup function it can get pretty hairy when the effect gets complex.

My exact use case was reacting to audio device selection changes by creating multiple MediaStream instances conditionally and hooking them up with some other parts of the app. Since MediaStreams are created conditionally I had several if statements and each of them called onInvalidate passing some cleanup function. I was surprised that some resources weren't properly released when I changed the devices.

This feature request doesn't require any end user facing API changes, rather changing of Vue internals to keep track of multiple callbacks instead of just the last one.

In the meantime I solved my use case by creating a crude wrapper around watchEffect that does what I need. While it was easy to implement this workaround I think it would greatly improve watchEffect ergonomics if we had it in Vue out of the box.

import { watchEffect as vueWatchEffect } from 'vue'
/**
 * Vue's watchEffect remembers only the last cleanup function. This wrapper
 * allows to register multiple cleanup functions
 */
export const watchEffect: typeof vueWatchEffect = (effect, opts) => {
  return vueWatchEffect((onInvalidate) => {
    let invalidated = false

    const cleanupFns = new Set<() => void>()
    const registerCleanupFn = (cleanupFn: () => void) => {
      if (invalidated) {
        cleanupFn()
      } else {
        cleanupFns.add(cleanupFn)
      }
    }

    const doCleanup = () => {
      invalidated = true
      cleanupFns.forEach((cleanupFn) => {
        cleanupFn()
      })
      cleanupFns.clear()
    }

    onInvalidate(doCleanup)
    effect(registerCleanupFn)
  }, opts)
}

What does the proposed API look like?

no changes

avatar
Dec 26th 2023

My personal workaround is this function:

function fixOnCleanup(onCleanup: (cleanupFn: () => void) => void): (cleanupFn: () => void) => void {
    const cleanupFns: Array<() => void> = [];
    onCleanup(() => {
        for (const cleanupFn of cleanupFns) {
            cleanupFn();
        }
    });

    return (cleanupFn: () => void) => {
        cleanupFns.push(cleanupFn);
    };
}

watch(valueRef, (newValue, oldValue, onCleanup_) => {
    const onCleanup = fixOnCleanup(onCleanup_);

    onCleanup(() => {
        ...
    });
});