Library for stateful and reactive programming: observables, ownership and lifetimes, automatic deinit, procedural reactivity. Lightweight alternative to MobX.
npm install espoObservables via proxies. Particularly suited for UI programming. Features:
* Implicit sub/resub, just by accessing properties.
* Implicit trigger, by property modification.
* Any object can be made observable; see obs. Subclassing is available but not required.
* Arbitrary properties can be observed. No need for special annotations. (Opt-out via non-enumerables.)
* Implicit resource cleanup: automatically calls .deinit() on removed/replaced objects.
* Your objects are squeaky clean, with no added library junk other than mandatory .deinit().
* Nice-to-use in plain JS. Doesn't rely on decorators, TS features, etc.
* Easy to wire into any UI system. Comes with optional adapters for React (react.mjs) and custom DOM elements (dom.mjs).
Tiny (a few KiB _un_-minified) and dependency-free. Native JS module.
Known limitations:
* No special support for collections.
* When targeting IE, requires transpilation and ES2015 polyfills.
* Usage
* Install
* Trichotomy of proxy/handler/target
* Implicit sub/resub
* Auto-deinit
* Enumerable vs non-enumerable
* new Proxy vs function vs subclass
* API
* interface isDe
* interface isObs
* interface isTrig
* interface isSub
* interface isSubber
* interface isRunTrig
* function ph
* function self
* function de
* function obs
* function comp
* function lazyComp
* class Deinit
* class Obs
* class Comp
* class LazyComp
* class Loop
* class Moebius
* class DeinitPh
* class ObsPh
* class CompPh
* class LazyCompPh
* class Sched
* const sch
* function mut
* function deinit
* Undocumented
``sh`
npm i -E espo
Espo uses native JS modules, which work both in Node and browsers. It's even usable in browsers without a bundler; use either an importmap (polyfillable), an exact file path, or a full URL.
`js
import * as es from 'espo'
import * as es from './node_modules/espo/espo.mjs'
import * as es from 'https://cdn.jsdelivr.net/npm/espo@0.8.2/espo.mjs'
`
While Espo is fairly magical (🧚 fairy magical?), the user must be aware of proxies. In Espo, your objects aren't "observables" in a classic sense, full of special properties and methods. Instead, they remain clean, but wrapped into a Proxy, together with a _proxy handler_ object, which is the actual state of the observable, with subscribers, explicit sub/unsub methods, and so on.
Mostly, you wrap via obs or by subclassing Obs, and just use the resulting proxy as if it was the original object. Implicit sub/resub relies on proxy features. For explicit sub/unsub, access the proxy handler via ph, and call the handler's methods.
`js
import * as es from 'espo'
const target = {someProp: 'someVal'}
// Same as es.obs(target), but without support for .onInit/.onDeinit.
const obs = new Proxy(target, new es.ObsPh())
obs.someProp // 'someVal'
// The es.ObsPh object we instantiated before.
const ph = es.ph(obs)
ph.sub(function onTrigger() {})
`
Espo provides features, such as comp or Loop, where you provide a function, and within that function, access to any observables' properties automatically establishes subscriptions. Triggering those observables causes a recomputation or a rerun. The timing of these events can be fine-tuned for your needs; lazyComp doesn't immediately recompute, and Moebius doesn't immediately rerun.
See Loop for a simple example.
This is extremely handy for UI programming. Espo comes with optional adapters for React (react.mjs) and custom DOM elements (dom.mjs).
Example with React:
`js
import * as es from 'espo'
import * as esr from 'espo/react.mjs'
// Base class for your views. Has implicit reactivity in render.
class View extends Component {
constructor() {
super(...arguments)
esr.viewInit(this)
}
}
const one = es.obs({val: 10})
const two = es.obs({val: 20})
// Auto-updates on observable mutations.
class Page extends View {
render() {
return
Example with custom DOM elements:
`js
import * as es from 'espo'
import * as ed from 'espo/dom.mjs'const one = es.obs({val: 10})
const two = es.obs({val: 20})
class TotalElem extends ed.RecElem {
// Runs on initialization and when triggered by observables.
run() {
this.textContent =
current total: ${one.val + two.val}
}
}
customElements.define(a-total, TotalElem)class TotalText extends ed.RecText {
constructor() {super(), this.upd()}
// Runs on initialization and when triggered by observables.
run() {
this.textContent =
current total: ${one.val + two.val}
}
}
`$3
In addition to observables, Espo implements automatic resource cleanup, relying on the following interface:
`ts
interface isDe {
deinit(): void
}
`On all Espo proxies, whenever an enumerable property is replaced or removed, the previous value is automatically deinited, if it implements this method.
All Espo proxies provide a
.deinit method, which will:* Call
.deinit on the proxy handler, dropping all subscriptions.
* Call .deinit on all enumerable properties of the target (if implemented).
* Call .deinit on the proxy target (if implemented).This allows your object hierarchies to have simple, convenient, correct lifecycle management.
$3
Non-enumerable properties are exempt from all Espo trickery. Their modifications don't trigger notifications, and they're never auto-deinited.
In the following example,
.owned is auto-deinited, but .owner is not:`js
class State extends es.Deinit {
constructor(owner, owned) {
super()
this.owned = owned
Object.defineProperty(this, 'owner', {value: owner})
}
}new State(owner, owned).deinit()
// Implicitly calls:
owned.deinit()
`For convenience, use the shortcuts
priv and privs:`js
es.privs(this, {owner})
`$3
The core of Espo's functionality is the proxy handler classes. Ultimately, functions like
obs and classes like Obs are shortcuts for the following, with some additional wiring:`js
new Proxy(target, new es.ObsPh())
`Customization is done by subclassing one of the proxy handler classes, such as
ObsPh, and providing the custom handler to your proxies, usually via static get ph() in your class. Everything else is just a shortcut.Deinit or Obs is that after the super() call, this is already a proxy. This matters for bound methods, passing this to other code, and so on. When implementing your own observable classes, the recommendation is to _subclass Deinit or Obs when possible_, to minimize gotchas.API
Also see changelog: changelog.md.
$3
Short for "is deinitable". Implemented by every Espo object. All Espo proxies support automatic deinitialization of _arbitrary_ properties that implement this interface, when such properties are replaced or removed.
`ts
interface isDe {
deinit(): void
}
`$3
Short for "is observable". Implemented by Espo proxy handlers such as
ObsPh.`ts
interface isObs {
trig () : void
sub (sub: isSub) : void
unsub (sub: isSub) : void
deinit () : void
}es.isObs(es.obs({})) // false
es.isObs(es.ph(es.obs({}))) // true
`$3
Short for "is triggerable". Part of some other interfaces.
`ts
interface isTrig {
trig(): void
}
`$3
Short for "is subscriber / subscription". Interface for "triggerables" that get notified by observables. May be either a function, or an object implementing
isTrig.isSub is used for explicit subscriptions, such as ObsPh.prototype.sub, and must also be provided to Loop.Support for objects with a
.trig method, in addition to functions, allows to avoid "bound methods", which is a common technique that leads to noisy code and inefficiencies.`ts
type isSub = isTrig | () => void
`$3
Internal interface. Used for implementing implicit reactivity by
Loop and Moebius.`ts
interface isSubber {
subTo(obs: isObs): void
}
`$3
Moebius. Allows a two-stage trigger: .run is invoked immediately; .trig is invoked on observable notifications, and may choose to loop back into .run.`ts
interface isRunTrig {
run(...any): void
trig(): void
}
`$3
Takes an Espo proxy and returns its handler. In Espo, the proxy handler is the _actual_ observable in a traditional sense, with subs/unsubs/triggers.
Because JS has no standard support for fetching a proxy's handler, this relies on special-case support from Espo proxy handlers, and will not work on others.
`js
const ref = es.obs({val: 10})// Runtime exception: method doesn't exist.
ref.sub(onUpdate)
// This works:
ph returns an instance of ObsPh,
// which is the actual observable state.
es.ph(ref).sub(onUpdate)// Clears any current subscriptions,
// without invoking
deinit on the target.
es.ph(ref).deinit()function onUpdate() {}
`$3
Takes an Espo proxy and returns the underlying target. Mostly for internal use.
Because JS has no standard support for fetching a proxy's target, this relies on special-case support from Espo proxy handlers, and will not work on others.
$3
Shortcut for:
`js
new Proxy(ref, es.deinitPh)
`Takes an arbitrary object and returns a proxy that supports automatic deinit of replaced/removed enumerable properties that implement
isDe. Non-enumerable properties are exempt. The proxy always implements isDe, which will deinit _all_ enumerable properties on the target.In all other respects, the proxy is the same as its target.
When implementing your own classes, the recommended approach is to subclass
Deinit instead. See new Proxy vs function vs subclass for the "why".$3
Shortcut for the following, with some additional wiring:
`js
new Proxy(ref, new es.ObsPh())
`Takes an arbitrary object and returns a proxy that combines the functionality of
de with support for implicit reactivity. In reactive contexts, such as during a Loop or Moebius run, accessing any of its enumerable properties, either directly or indirectly, will implicitly subscribe to this observable.In all other respects, the proxy is the same as its target.
When implementing your own classes, the recommended approach is to subclass
Obs instead. See new Proxy vs function vs subclass for the "why".If the target implements
.onInit, the handler will call it when adding the first subscription. If the target implements .onDeinit, the handler will call it when removing the last subscription:`js
class X {
constructor() {return es.obs(this)}
onInit() {console.log('initing')}
onDeinit() {console.log('deiniting')}
}
`To use a different proxy handler class, implement
static get ph() in your class; obs will prefer it:`js
class X {
constructor() {return es.obs(this)} static get ph() {return MyObsPh}
}
class MyObsPh extends es.ObsPh {}
// Proxy whose handler is an instance of
MyObsPh.
new X()
`$3
Shortcut for the following, with some additional wiring:
`js
new Proxy(ref, new es.CompPh(fun))
`obs which, in addition to its normal properties and behaviors, runs the attached function to compute additional properties, or recompute existing properties.Very lazy. The recomputation doesn't run immediately. It runs on the first attempt to access any property. Then, it reruns _only_ when triggered by one of the observables accessed by the callback. However, it subscribes to those observables _only_ when it has its own subscribers. If you never subscribe to the computation, it will compute no more than once.
Remembers which observables were accessed by the callback, either directly or indirectly. Whenever the computation has its own subscribers, it's also subscribed to those observables, and their triggers cause recomputations. Whenever the computation has _no_ subscribers, it's also _not_ subscribed to those observables, but still remembers them.
The callback _must be synchronous_: not
async function and not function*.When implementing your own classes, the recommended approach is to subclass
Comp instead. See new Proxy vs function vs subclass for the "why".`js
const one = es.obs({val: 10})
const two = es.obs({val: 20})const ref = es.comp({val: 30}, ref => {
ref.total = ref.val + one.val + two.val
})
// Allows active recomputation. Without this, changes would be ignored.
es.ph(ref).sub(function onUpdate() {})
console.log(ref.total) // 60
one.val = 40
console.log(ref.total) // 90
ref.deinit()
`Just like
obs, this function supports overriding the preferred proxy handler class via static get ph() in the target's class.$3
Shortcut for the following, with some additional wiring:
`js
new Proxy(ref, new es.LazyCompPh(fun))
`comp: when triggered by observables accessed in the callback, it merely marks itself as "outdated", but doesn't immediately recompute, and therefore doesn't notify its own subscribers of any changes. You must _pull_ the data from it. It doesn't _push_.Aside from the lazy recomputation, it remains a proper observable. Any modifications of its properties may trigger notifications.
$3
de. Implemented as:`js
class Deinit {constructor() {return de(this)}}
`For your classes, subclassing this is recommended over
de. See new Proxy vs function vs subclass for the "why".$3
obs. Implemented as:`js
class Obs {constructor() {return obs(this)}}
`For your classes, subclassing this is recommended over
obs. See new Proxy vs function vs subclass for the "why".$3
comp. Implemented as:`js
class Comp {constructor(fun) {return comp(this, fun)}}
`For your classes, subclassing this is recommended over
comp. See new Proxy vs function vs subclass for the "why".$3
lazyComp. Implemented as:`js
class LazyComp {constructor(fun) {return lazyComp(this, fun)}}
`For your classes, subclassing this is recommended over
lazyComp. See new Proxy vs function vs subclass for the "why".$3
Tool for implicit reactivity. Code that runs within a
Loop implicitly subscribes to Espo observables just by accessing their properties. Especially suited for automatic UI updates.See the example below. Also see the optional adapter
dom.mjs, which implements implicit reactivity in custom DOM elements.`js
const one = es.obs({val: 10})
const two = es.obs({val: 20})const loop = new es.Loop(() => {
console.log('current total:', one.val + two.val)
})
loop.trig()
// current total: 30
one.val = 30
// current total: 50
two.val = 40
// current total: 70
loop.deinit()
`$3
Loop, more flexible, and more complex to use. Instead of taking _one_ function, it takes _two_, in the form of an object that implements .run and .trig. Calling moebius.run invokes ref.run, automatically subscribing to any observables whose properties were accessed. When those properties trigger, moebius will call ref.trig, which has the freedom to loop back into moebius.run, directly or indirectly. Whether this becomes a loop, is completely up to you. Also, unlike Loop, moebius.run is not void: it returns the result of calling the underlying ref.run.It's difficult to provide an example not too abstract. See the optional adapter
react.mjs, which uses Moebius for automatic reactivity in React components.$3
de and Deinit. Stateless; Espo uses a single global instance exported as deinitPh. Exported for subclassing.$3
obs and Obs. Exported for customization by advanced users.This is the real "observable" in a traditional sense. It implements
isObs, handling subscription/unsubscription/notification.Use
obs or Obs to turn arbitrary objects into observable proxies. For explicit sub/unsub, use ph to access the underlying ObsPh. For implicit sub/unsub via Loop or Moebius, accessing the handler is unnecessary.To use a custom subclass of
ObsPh (or something totally different), either directly pass it to new Proxy, or implement static get ph() in your target class:`js
class X extends es.Obs {
static get ph() {return MyObsPh}
}class MyObsPh extends es.ObsPh {}
`$3
comp and Comp. Exported for customization by advanced users.$3
lazyComp and LazyComp. Exported for customization by advanced users.$3
Tool for pausing and batching observable notifications. Only one instance exists, exported as
sch. See below.$3
Sched instance, used by Espo for pausing and batching observable notifications.Use
sch.pause and sch.resume, _always_ via try/finally, to group multiple triggers into one. When the scheduler is paused, observable triggers are queued up inside the scheduler, and flushed when it's resumed.pause and resume are reentrant/stackable: it's okay to call them while already paused. The scheduler keeps a counter, and flushes when the counter goes down to 0.mut.Example:
`js
const ref = es.obs({one: 10, two: 20, three: 30})es.sch.pause()
try {
ref.one++
ref.two++
ref.three++
}
finally {
es.sch.resume()
}
`$3
sch, to avoid multiple triggers/notifications.`js
const ref = es.obs({one: 10, two: 20, three: 30})// This will set two properties, but trigger exactly once.
es.mut(ref, {one: 40, two: 50})
ref
// {one: 40, two: 50, three: 30}
`$3
Calls
val.deinit() if implemented. Otherwise a nop. Convenient for deiniting arbitrary values.$3
Espo is friendly to 🔧🐒. Many useful tools are exposed but undocumented, to avoid bloating the docs. Take the time to skim the source file
espo.mjs`.https://unlicense.org
I'm receptive to suggestions. If this library _almost_ satisfies you but needs changes, open an issue or chat me up. Contacts: https://mitranim.com/#contacts