218 lines
6.9 KiB
Markdown
218 lines
6.9 KiB
Markdown
# FRP tools
|
|
|
|
Observer and Computed value stores designed to work together for storing real state and derived
|
|
state.
|
|
|
|
# [observable](./src/observable.js)
|
|
|
|
`observable` is a simple value store that can report when its value changes. It is good for wrapping
|
|
external props passed into a component so compute types can dependent on them. It can also be used
|
|
to receive events such as _window.onresize_ to always provide the current viewport size.
|
|
|
|
NOTE: Javascript has a proposal for a thing called an
|
|
[Observable](https://github.com/tc39/proposal-observable). This is not an implementation of that.
|
|
They do serve similar functions (provide a way to communicate when a value changes) but the tc39
|
|
proposal is more about event input sources and can communicate errors and be extended. This
|
|
implementation is designed to be as small and simple as possible. Extending it is done via
|
|
[Computed] instances depending on them. I may rename `observable` to `property` in a future major
|
|
release to avoid this confusion.
|
|
|
|
## Usage
|
|
|
|
### Creation
|
|
|
|
Creates and sets initial value to `true`
|
|
|
|
```js
|
|
const inViewport = observable(true);
|
|
```
|
|
|
|
### Read
|
|
|
|
Call it to receive the stored value.
|
|
|
|
```js
|
|
if (inViewport()) {
|
|
/* inViewport is truthy */
|
|
}
|
|
```
|
|
|
|
### Change
|
|
|
|
Call it passing the new value. If any computed stores depend on this value they will be marked dirty
|
|
and re-evaluated the next time they are read from.
|
|
|
|
```js
|
|
inViewport(false);
|
|
```
|
|
|
|
### Subscribe to changes
|
|
|
|
Call the `subscribe` method with a callback that will be called when the observable is changed to a
|
|
different value. The returned function can be called to unsubscribe from the observable. When called
|
|
it will provide the count of remaining subscriptions.
|
|
|
|
```js
|
|
const unsubscribe = inViewport.subscribe(console.log.bind(console));
|
|
const remainingSubscriptionCount = unsubscribe();
|
|
|
|
inViewport.unsubscribeAll(); // Call unsubscribeAll to remove child observables/computeds.
|
|
```
|
|
|
|
### Provide a comparator for complex types
|
|
|
|
When storing a type that is not determined to be equal with simple equality (===), provide a
|
|
function to determine in the new provided value should be propagated to dependents.
|
|
|
|
```js
|
|
function setEquals(a, b) {
|
|
return (
|
|
a instanceof Set &&
|
|
b instanceof Set &&
|
|
[...a].reduce((acc, d) => acc && b.has(d), true) &&
|
|
[...b].reduce((acc, d) => acc && a.has(d), true)
|
|
);
|
|
}
|
|
|
|
const a = observable(new Set([1, 2]), setEquals);
|
|
```
|
|
|
|
# [computed](./src/computed.js)
|
|
|
|
`computed` is a functional store that depends on the values of observables or other computeds. They
|
|
derive value from observables rather than store value and hence cannot be set directly.
|
|
|
|
## Behavior
|
|
|
|
A `computed` will subscribe to its dependencies in such a way that it will be marked as _dirty_ when
|
|
any dependency changes. Whenever it is read from, if will recompute its result if the _dirty_ flag
|
|
is set, otherwise it just return the stored result from the last time it computed.
|
|
|
|
## Usage
|
|
|
|
### Creation
|
|
|
|
```js
|
|
const showDialog = computed(
|
|
(inVP, shouldShow) => inVP && shouldShow, // computation function
|
|
[inViewport, shouldShow] // array of dependencies, can be either observable or computed
|
|
);
|
|
```
|
|
|
|
### Read
|
|
|
|
```js
|
|
if (showDialog()) {
|
|
/* showDialog() is truthy */
|
|
}
|
|
```
|
|
|
|
Call it to receive the stored value, recomputing if necessary.
|
|
|
|
### Subscribe to changes
|
|
|
|
Call the subscribe method with a callback that will be called when the computed result changes to a
|
|
different value. The returned function can be called to unsubscribe from the observable. When called
|
|
it will provide the count of remaining subscriptions.
|
|
|
|
```js
|
|
const unsubscribe = inViewport.subscribe(console.log.bind(console));
|
|
const remainingSubscriptionCount = unsubscribe();
|
|
```
|
|
|
|
**NOTE**: Subscribing to a computed forces it to recompute every time an upstream dependency
|
|
changes. This could negatively performance if it depends on multiple values that change sequentially
|
|
and the computation function is non-trivial. For example:
|
|
|
|
```js
|
|
const inViewport = observable(false);
|
|
const shouldShow = observable(false);
|
|
|
|
const showDialog = computed((inVP, shouldShow) => inVP && shouldShow, [inViewport, shouldShow]);
|
|
|
|
inViewport(true); // showDialog marked as dirty but does not recompute its stored result.
|
|
shouldShow(true); // showDialog is already marked as dirty. Nothing else happens.
|
|
showDialog(); // showDialog recomputes its stored result and unsets the dirty flag.
|
|
|
|
// adding a subscription will change showDialog's internal behavior
|
|
showDialog.subscribe(console.log.bind(console));
|
|
|
|
inViewport(false); // showDialog result recomputed and `false` is written to the console.
|
|
shouldShow(false); // showDialog result recomputed, console.log is not called.
|
|
showDialog(); // showDialog does not recompute, console.log is not called. `false` is returned.
|
|
|
|
showDialog.detach(); // Call detach to remove this computed from the logic tree.
|
|
showDialog.unsubscribeAll(); // Call unsubscribeAll to remove child observables/computeds.
|
|
```
|
|
|
|
### Provide a comparator for complex types
|
|
|
|
When the computed result is a type that is not determined to be equal with simple equality (===),
|
|
provide a function to determine in the new provided value should be propagated to dependents.
|
|
|
|
```js
|
|
function setEquals(a, b) {
|
|
return (
|
|
a instanceof Set &&
|
|
b instanceof Set &&
|
|
[...a].reduce((acc, d) => acc && b.has(d), true) &&
|
|
[...b].reduce((acc, d) => acc && a.has(d), true)
|
|
);
|
|
}
|
|
|
|
function _intersection(a, b) {
|
|
return new Set([...a].filter(x => b.has(x)));
|
|
}
|
|
|
|
const a = observable(new Set([1, 2]), setEquals);
|
|
const b = observable(new Set([2, 3]), setEquals);
|
|
const intersection = computed(_intersection, [a, b], setEquals);
|
|
```
|
|
|
|
# [bundle](./src/bundle.js)
|
|
|
|
`bundle` is a wrapper around a group of `observables` for the purpose of applying changes to all of
|
|
them at once without having to trigger a subscription that may depend on more than observable in the
|
|
group.
|
|
|
|
Another way to think of a `bundle` is an `observable` that takes an object and exposes the
|
|
properties as individual observables.
|
|
|
|
## Behavior
|
|
|
|
A `bundle` wraps observables to intercept dependency hooks in such a way that updating all
|
|
observables can happen at once before any downstream `computeds` are evaluated. A bundle returns a
|
|
function that can be called with an object to set values for the mapped member observables.
|
|
|
|
## Usage
|
|
|
|
### Creation
|
|
|
|
```js
|
|
const layoutEventBundle = bundle({
|
|
width: observable(1),
|
|
height: observable(2)
|
|
});
|
|
const ratio = computed((a, b) => a / b, [layoutEventBundle.width, layoutEventBundle.height]);
|
|
ratio.subscribe(render);
|
|
```
|
|
|
|
### Change Member Observables atomically
|
|
|
|
```js
|
|
layoutEventBundle({ width: 640, height: 480 });
|
|
```
|
|
|
|
`ratio` would normally be evaluated twice and `render` would be called after each intermediate
|
|
change. But bundle allows both values to change and `ratio` will only be evaluated once and `render`
|
|
called once.
|
|
|
|
### Change Member Observables individually
|
|
|
|
```js
|
|
layoutEventBundle.width(640);
|
|
layoutEventBundle.height(480);
|
|
```
|
|
|
|
The observables exposed by the bundle can also be updated apart from their grouping.
|