BeTomorrow / micro-observables

A simple Observable library that can be used for easy state management in React applications.
MIT License
104 stars 8 forks source link

avoid updates when value did not change (with custom equality function) #25

Open pothos-dev opened 2 years ago

pothos-dev commented 2 years ago

I have a graph of observables, that originate from a rapidly changing one (a redux store that we want to migrate away from).

I want derived observables to only propagate updates if their value actually changed. I want to use a custom equality function to determine if something changed (e.g. structual equality over reference equality).

I know that I can compare current and last value inside subscribe, but most of my observables are not directly subscribed, but are used as inputs to other observables, which are unnessecarily recomputed.

I propose an additional, optional parameter isEqual(next: T, current: T): boolean, to .select() and .compute(), after the lambda function, where you can pass in an equality function that is evaluated on every update after the initialitation of the observable. If it returns true, the observable is not updated with the computed value.

import { isEqual } from "lodash"

const signalSpeed: Observable<Speed> = reduxStore.select(store => ({
  pxPerMm: store.config?.getSettings("pxPerMm") ?? 0,
  mmPerSec: store.config?.getSettings("signalSpeed") ?? 0,
}), isEqual)
lubieowoce commented 2 years ago

Hey, looks like we can work around this "in userspace", using the previous value we get in subscribe!

import { observable, Observable } from 'micro-observables';

export type Equality<T> = (a: T, b: T) => boolean;

/**
 * Takes an observable and produces a new one, which updates
 * only if equals returns false.
 * Example usage:
 *  const obs2 = sameIf(isEqual)(obs1)
 **/
export function sameIf<T>(equals: Equality<T>) {
  return (original: Observable<T>): Observable<T> => {
    const derived = observable(original.get());
    original.subscribe((newVal: T, oldVal: T) => {
      if (!equals(newVal, oldVal)) {
        derived.set(newVal);
      }
    });
    return derived;
  };
}

// or, if you prefer it as a constructor
export function observableMemo<T>(value: T, equals: Equality<T>): Observable<T> {
  return sameIf(equals)(observable(value));
}

(sorry about the indents, github seems to mess them up no matter what i do)

codesandbox with some react code to check it out: https://codesandbox.io/s/little-morning-jcq03u?file=/src/App.tsx

You'd still need to wrap everything with sameIf(myEquals)(...), but that's better than nothing i guess... or perhaps do something like this if you wanna save yourself some typing:

const selectIf = (equals) => (obs, selector) => sameIf(equals)(obs.select(selector))

Some alternative names: memoIf, keepIf, updateUnless. The original one was memoIf, but i feel like "memo" is a bit too general

pothos-dev commented 2 years ago

This is something I am doing right now, but I am afraid that this solution is prone to memory leaks.

I assume that a regularly derived observable via .select() can be garbage collected when the last reference to it is deleted.

Using the solution outlined by lubieowoce, by explicitly creating a subscription on the source observable and referencing the target observable, we bind the lifetime of the target observable to the source observable, and cannot simply "let it go".

I noticed that there is an undocumented Plugin architecture in the library that supports stuff like onAttach etc.. so I think we might be able to make it memory safe using this, but since it's undocumented, kinda hard to say.

simontreny commented 2 years ago

This is something I am doing right now, but I am afraid that this solution is prone to memory leaks.

I assume that a regularly derived observable via .select() can be garbage collected when the last reference to it is deleted.

Yes, you're right, deriving observables using subscribe() will cause memory leaks as the source observables won't be garbage collected unless the unsubscribe function returned by subscribe() is explicitly called.

I agree that equality function is definitely something that should be built-in directly in micro-observables. We actually already added support for it in the next major version of the library (that is not released yet). I've quickly tried to backport it into the 1.x version but unfortunately, this is not straightforward. The next version should hopefully be released in the next few weeks.

I noticed that there is an undocumented Plugin architecture in the library that supports stuff like onAttach etc.. so I think we might be able to make it memory safe using this, but since it's undocumented, kinda hard to say.

This API was an experiment to add support for persistence and dev tools to micro-observables but we will drop it in 2.x in favor of another mechanism. Anyway I don't think it can be used to achieve what you want here.

pkieltyka commented 1 year ago

hey @simontreny any updates on this one? would be cool to see 2.x and also if you've considered react v18 concurrent mode + useSyncExternalStore ?