Computed observables are based on a lower-level primitive for subscribing to observables, which is sometimes handy to use directly.
The subscribe()
function allows subscribing to several observables at once.
For example, if we have some existing observables (which may be instances of Computed
),
we can subscribe to them explicitly:
const obs1 = Observable.create(owner, 5);
const obs2 = Observable.create(owner, 12);
subscribe(obs1, obs2, (use, v1, v2) => console.log(v1, v2));
or implicitly by using use(obs)
function, which allows dynamic subscriptions:
subscribe(use => console.log(use(obs1), use(obs2)));
In either case, the callback is called immediately, and then again whenever obs1
or obs2
is
changed.
Creating a subscription allows any number of dependencies to be specified explicitly, and their
values will be passed to the callback. These may be combined with automatic dependencies
detected using use()
. Note that constructor dependencies have less overhead.
subscribe(...deps, ((use, ...depValues) => READ_CALLBACK));
An observable may contain a disposable object. It may be used as the owner when an object is
created with the create
static method, or it may take ownership of an object using the
Observable's autoDispose()
method.
const obs = Observable.create<MyClass|null>(owner, null);
MyClass.create(obs, ...args) // Preferred
obs.autoDispose(MyClass.create(null, ...args)) // Equivalent
Either of these usages will set the observable to the newly created MyClass
object. The
observable will dispose the owned object when it's set to another value, or when it itself is
disposed.
To create an observable with an initial disposable value owned by this observable, use
obsHolder
:
const obs = obsHolder<MyClass>(MyClass.create(null, ...args));
This is needed because using simply observable<MyClass>(value)
would not cause the observable to
take ownership of value (i.e. to dispose it later).
ObsArray
extends a plain Observable to allow for more efficient observation of array changes.
You may use a regular observable with an array for its value. To set it to a new value, you'd need
to provide a new array, or you could modify the array in place, and call
Observable.setAndTrigger(array)
(to ensure that listeners are called even though the value is
still the same array object). In either case, listeners will be called with the new and old values,
but with no info on what changed inside the array.
For simple changes, such as those made with .push()
and .splice()
methods, ObsArray
allows
for more efficient handling of the change by calling listeners with splice info in the third
argument. This is used by dom.forEach()
for more efficient handling of array-driven DOM.
For example:
const arr = obsArray<string>();
arr.push("b", "c");
arr.splice(1, 0, "b1", "b2");
arr.splice(2, 2)
Related to this, there is also a computedArray()
, which allows mapping each item of an ObsArray
through a function, passing through splice info for efficient handling of small changes.
const array = obsArray<string>([]);
const mapped = computedArray(array, x => x.toUpperCase());
array.push("a", "b", "c");
mapped.get(); // Returns ["A", "B", "C"]
It also allows mapping an observable or a computed whose value is an ObsArray
. In which case, it
will listen both to the changes to the top-level observable, and to the changes in the
ObsArray
:
const a = obsArray<string>([]);
const b = obsArray<string>(['bus', 'bin']);
const toggle = Observable.create(null, true);
const array = Computed.create(null, use => use(toggle) ? a : b);
const mapped = computedArray(array, x => x.toUpperCase());
mapped.get(); // Returns [], reflecting content of a
a.push('ace');
mapped.get(); // Returns ['ACE']
toggle.set(false); // array now returns b
mapped.get(); // Returns ['BUS', 'BIN'] reflecting content of b
There is no need or benefit in using computedArray()
if you have a computed()
that returns a
plain array. It is specifically for the case when you want to preserve the efficiency of
ObsArray
when you map its values.
Both ObsArray
and ComputedArray
may be used with disposable elements as their owners. E.g.
const arr = obsArray<D>();
arr.push(D.create(arr, "x"), D.create(arr, "y"));
arr.pop(); // Element "y" gets disposed.
arr.dispose(); // Element "x" gets disposed.
const values = obsArray<string>();
const compArr = computedArray<D>(values, (val, i, compArr) => D.create(compArr, val));
values.push("foo", "bar"); // D("foo") and D("bar") get created
values.pop(); // D("bar") gets disposed.
compArr.dispose(); // D("foo") gets disposed.
Note that only the pattern above works: the observable array may only be used to take ownership of those disposables that are added to it as array elements.
One more tool available for observale arrays is a makeLiveIndex(owner, obsArr)
. It
returns a new observable representing an index into the array. The created "live index" observable
can be read and written, and its value is clamped to be a valid index. The index is only null if
the array is empty. As the array changes (e.g. via splicing), the live index is adjusted to
continue pointing to the same element. If the pointed element is deleted, the index is adjusted to
after the deletion point.
A pureComputed()
is a variant of computed()
suitable for use with a pure read function
(free of side-effects). A pureComputed
only subscribes to its dependencies when something
subscribes to the pureComputed
itself. At other times, it is not subscribed to anything, and
calls to get()
will recompute its value each time.
Its syntax and usage are otherwise exactly as for a computed.
In addition to being cheaper when unused, a pureComputed()
also avoids leaking memory when
unused (since it's not registered with dependencies), so it is not necessary to dispose it.
When work happens in response to changing observables, or when computed observables are used to create non-trivial objects, it may be useful to understand the order of evaluation of computeds.
Consider this toy example:
const amount = Observable.create(null, 12.95);
const tax = Computed.create(null, use => use(amount) * 0.08875);
const tip = Computed.create(null, use => use(amount) * 0.20);
const total = Computed.create(null, use => use(amount) + use(tax) + use(tip));
If you call amount.set(18.50)
, then tax
, tip
, and total
need to be recomputed. GrainJS
takes care of recomputing them in a correct order: tax
, then tip
, then total
. This way by
the time total
is calculated, it sees up-to-date values for all of its dependencies.
Note that this is a difference to how Knockout.js works.
In Knockout, changing the amount
would trigger an update to tax
, tip
, and total
(which
depend on it directly). While recalculating tax
, it would trigger total
, which depends on tax
,
and it would get recalculated at that point (using a stale value for tip
). Then when tip
is
updated, it would trigger tax
calculation again (this time getting the correct value). Finally,
it will recalculate total
a third time (the time triggered by the initial change to amount
).
Improving on this is part of the motivation for GrainJS. When a computed is evaluated, it keeps track of a "priority" value, updating it to be greater than the priority of any dependency. GrainJS uses a priority queue to recalculate values in the order of these priorities. This helps ensure that when a computed is evaluated, all its dependencies are up to date.
Sometimes, it's useful to change multiple observables before recalculating any computed values.
This is possible using bundleChanges()
method:
import {bundleChanges} from 'grainjs';
const taxRate = Observable.create(null, 0);
const tipRate = Observable.create(null, 0);
...
const total = Computed.create(null, use => ...use(taxRate)...use(tipRate)...);
bundleChanges(() => {
taxRate.set(0.0875);
tipRate.set(0.20);
});
This sets both taxRate
and tipRate
before calling any computed callbacks. This way, by the
time such callbacks are called, they see up-to-date values for both observables.
There is one known difficulty with GrainJS computeds. To avoid the possibility of infinite loops,
a computed callback will not be called more than once in response to a single change (or a single
bundleChanges()
call). Normally that's what you want. But you could create a situation when
the single evaluation isn't enough.
Here's a contrived example:
const amountObs = Observable.create(null, 0);
const discountObs = Observable.create(null, 0);
const total = Computed.create(null, use => use(amountObs) - use(discountObs));
subscribe(amountObs, (use, amount) => {
discountObs.set(amount >= 100 ? 5 : 0);
});
Here, a change in the amount, e.g. amountObs.set(200)
, will trigger a recompute of total
,
producing a value of 200. Then the subscribed callback runs, and sets the discountObs
to 5. That
ought to trigger a recalculation of total
to produce a correct value of 195, but because total
was already calculated once in this update, it will not be calculated again. It will stay 200.
The issue here is that GrainJS has no way to know that discountObs
depends on amountObs
-- the
update happens manually in a subscription. Replacing it with a computed would fix the issue:
const discountObs = Computed.create(null, amountObs, (use, amount) => (amount > 100 ? 5 : 0));
(If you run into a legitimate situation where this creates a problem, please open an issue. It's an area of potential further work.)