justin-schroeder / arrow-js

Reactivity without the framework
https://arrow-js.com
MIT License
2.38k stars 49 forks source link

How to unwatch #97

Open IsaacLehman opened 8 months ago

IsaacLehman commented 8 months ago

Hey!

Really loving ArrowJS - super easy to work with :)

One issue I have found is no way to "un watch". Take for example:

It would be really helpful if the watch returned some sort of reference which we could then remove on unmount.

A common time this happens is with multi-page apps. When spanning between pages, elements are constantly getting added and removed from the dom. After a bit you just have a bunch of old watch's sitting out there.

Any suggestions are appreciated! Thanks!

IsaacLehman commented 7 months ago

Hey @justin-schroeder !

Had some time today so played around with ways to add in a cleanup reference for watch. Appreciate any feedback you may have.

I believe this edit to the reactive.ts file would allow cleaning up a watch function as needed:

/**
 * Watch a function and track any reactive dependencies on it, re-calling it if
 * those dependencies are changed. Returns a function to unwatch.
 * @param  {CallableFunction} fn
 * @param  {CallableFunction} after?
 * @returns [unknown, () => void]
 */
export function w<
  T extends (...args: any[]) => unknown,
  F extends (...args: any[]) => any | undefined
>(fn: T, after?: F): [F extends undefined ? ReturnType<T> : ReturnType<F>, () => void] {
  const trackingId = Symbol()
  // Ensure a new dependency tracker for this watch instance
  if (!dependencyCollector.has(trackingId)) {
    dependencyCollector.set(trackingId, new Map())
  }
  let currentDeps: Map<ReactiveProxy<DataSource>, Set<DataSourceKey>> = new Map()
  const queuedCallFn = queue(callFn)

  function callFn() {
    // Reset dependencies for this call
    dependencyCollector.set(trackingId, new Map())
    const value: unknown = fn()
    const newDeps = dependencyCollector.get(trackingId) as Map<ReactiveProxy<DataSource>, Set<DataSourceKey>>
    // Clean up after getting the new dependencies
    dependencyCollector.delete(trackingId)
    // Remove old observers
    currentDeps.forEach((propertiesToUnobserve, proxy) => {
      propertiesToUnobserve.forEach((prop) => proxy.$off(prop, queuedCallFn))
    })
    // Add new observers
    newDeps.forEach((properties, proxy) => {
      properties.forEach((prop) => proxy.$on(prop, queuedCallFn))
    })
    // Update the current dependencies
    currentDeps = newDeps
    return after ? after(value) : value
  }

  // Setup initial call and observers
  const result = callFn()

  // Setup unwatchfunction
  function unwatch() {
    // Remove all observers set by this watch
    currentDeps.forEach((propertiesToUnobserve, proxy) => {
      propertiesToUnobserve.forEach((prop) => proxy.$off(prop, queuedCallFn))
    })
    // Optional: If the watched function itself is reactive, remove this callback
    if (isReactiveFunction(fn)) fn.$off(callFn)
  }

  // If the function is reactive, setup to re-run on reactive function changes
  if (isReactiveFunction(fn)) fn.$on(callFn)

  // Return the result of the initial function call and the cleanup function
  return [result, unwatch];
}

Basically it would then return both the initial call result and a reference to the clean up function.

Example usage:

let unwatch;
onMount(() => {
  // Setup the watch
  const [watchResult, unwatch] = w(someReactiveFunction);

  // Do something with watchResult if needed
});

onUnmount(() => {
  // Clean up when the component unmounts
  unwatch();
});
justin-schroeder commented 7 months ago

I agree, this seems like it would do the ticket. It is a significant breaking change too however — which will probably be required anyway whenever I get around to implementing something like this. There are a number of refactors that have been worked on that havent yet seen the light of day, but as soon as I get more time to tackle this unwatch will be a high priority!