A subclass of isotropic-state that arranges state objects into a tree. Events published by a descendant bubble up to its ancestors, and any descendant can resolve context that an ancestor provides without knowing how deep in the tree it sits.
- Parent References: Every object gets an optional
parentstate property that is validated, change-observable, and protected against circular ancestry - Event Bubbling: Events published by a child are automatically distributed to its parent, its parent's parent, and so on to the root
- Ancestor Context: An object declares data by name. Any descendant reads it by that name without walking the tree itself
- Depth Independence: A consumer never references
parent.parent.parent, so the same class works at any depth and can be moved without changing its code - Declarative or Imperative: Context can be declared statically on the class with
_context, passed to the constructor, or provided at runtime withprovideContext() - Values, Properties, and Functions: Provide a static value, a live reference to a state property, or a function to call on access
- Reactive by Default: Property and function contexts participate in
isotropic-state's cross-object dependency tracking, so computed properties on descendants recompute when the provider changes - Automatic Cleanup: When a parent is destroyed, its children detach from it
npm install isotropic-state-contextimport _make from 'isotropic-make';
import _StateContext from 'isotropic-state-context';
// A class that provides context to its descendants.
const _Application = _make('Application', _StateContext, {}, {
_context: {
theme: 'themeName'
},
_state: {
themeName: {
initFunction: () => 'light'
}
}
}),
// A class that consumes it, at any depth.
_Widget = _make('Widget', _StateContext, {}, {
_computed: {
theme () {
return this.getContext({
key: 'theme'
});
}
}
});
{
const application = _Application(),
panel = _Widget({
parent: application
}),
button = _Widget({
parent: panel
});
console.log(button.theme); // light
button.on('themeChange', event => {
console.log(`button theme is now ${event.data.newValue}`);
});
application.themeName = 'dark';
// button theme is now dark
}The button never mentions application. It asks for theme and gets it from the nearest ancestor that provides it, and because the context points at a state property, the button's computed property recomputes when that property changes.
parent is an ordinary isotropic-state property, so everything you already know about state properties applies. It defaults to null, publishes a parentChange event, and can be set in the constructor config or reassigned later.
const parent = _StateContext(),
child = _StateContext({
parent
});
child.on('parentChange', event => {
console.log('reparented', event.data.oldValue, '->', event.data.newValue);
});
child.parent = null; // detaches from the parentA parent must be a State instance or null. undefined is normalized to null.
stateContext.parent = 'nope'; // throwsWhile a parent can be any State instance, it can only provide context if it is a StateContext instance.
Assigning a parent that already has the object somewhere in its ancestry throws, so the tree can never become a ring.
const a = _StateContext(),
b = _StateContext({
parent: a
});
a.parent = b; // throws: parent would create a circular ancestry
a.parent = a; // throwsThe traversal methods are defensive about this anyway. If a plain State instance with its own unvalidated parent property sits in the ancestry and closes a loop, getContext() and the rest visit each object once and then stop.
Assigning null to parent is the ordinary way to detach, but removing the parent as a distributor detaches it too.
child.removeDistributor(parent);
console.log(child.parent); // nullThis publishes a parentChange event exactly as child.parent = null would. The two operations are interchangeable, and there is no way to end up holding a parent that no longer distributes events.
When a parent is destroyed, each of its children sets parent to null and detaches. Destroying a parent does not destroy its children.
const parent = _StateContext(),
child = _StateContext({
parent
});
parent.destroy();
console.log(child.parent); // null
console.log(child.destroyed); // falseDestroying a child unsubscribes it from its parent so the parent does not retain a reference to it.
stateContextChain() is a generator that yields the instance, then the parent, then the grandparent, and so on to the root.
for (const ancestor of widget.stateContextChain()) {
console.log(ancestor);
}
const path = Array.from(widget.stateContextChain());root is a computed property holding the furthest ancestor: the last object stateContextChain() yields. An object with no parent is its own root, so root is never null on a live instance.
const application = _Application(),
panel = _Widget({
parent: application
}),
button = _Widget({
parent: panel
});
console.log(button.root === application); // true
console.log(application.root === application); // trueBecause it is an ordinary eager computed property, it publishes rootChange and participates in the dependency graph. Computing it reads the parent of every object between the instance and the root, so reparenting anything on that path recomputes it, not just reparenting the instance itself.
button.on('rootChange', event => {
console.log('moved to a new tree', event.data.newValue);
});
panel.parent = _Application(); // button's root changes tooDestroying a parent detaches its children, so a detached object's root becomes itself.
Being eager means every instance computes root during initialization and registers a dependency on each ancestor's parent. A subclass that does not want the cost can redeclare the property as lazy, which drops rootChange but keeps the value correct on read.
Setting parent registers the parent as an event distributor of the child, using the mechanism documented in isotropic-pubsub. Distribution is transitive, so an event published anywhere in the tree is seen by subscribers on every ancestor up to the root.
const application = _Application(),
panel = _Widget({
parent: application
}),
button = _Widget({
parent: panel
});
application.on('activate', event => {
console.log(`${event.publisher} activated`);
});
button.publish('activate'); // the subscription on application runsTwo properties on the event tell you where it came from:
event.publisheris the object that published the event, which is the button in the example aboveevent.distributoris the object whose subscription is currently running, which is the application
This applies to every event, including the change batch event, the ${propertyName}Change events generated by state properties, and the lifecycle events from isotropic-initializable and isotropic-pubsub.
Because state change events are named after the property, an ancestor that has a name property of its own will see nameChange events published by descendants that also have a name property. That is intended: multiple objects in a tree can publish the same event name, and a subscriber that only cares about its own changes should check the publisher.
application.on('nameChange', event => {
if (event.publisher !== application) {
return;
}
// only this object's own change
});Subscribers can stop an event from travelling further with event.stopDistribution().
There are two ways for an object to provide context.
_context is a static property chain, like _state and _computed. Every instance of the class provides everything the class declares, and subclasses inherit declarations from their ancestors and can override them.
const _Application = _make('Application', _StateContext, {
_getRequestHeaders () {
return {
authorization: `Bearer ${this.token}`
};
}
}, {
_context: {
// Shorthand: a property name.
theme: 'themeName',
// Shorthand: an inline function, called with the provider as `this`.
title () {
return `${this.applicationName} (${this.environment})`;
},
// A method name to call on access.
requestHeaders: {
getFunction: '_getRequestHeaders'
},
// A fixed value.
version: {
value: '1.0.0'
}
},
_state: {
applicationName: {
initFunction: () => 'Example'
},
environment: {
initFunction: () => 'production'
},
themeName: {
initFunction: () => 'light'
},
token: {
initFunction: () => ''
}
}
});This is the preferred form. Because the declaration exists on the class, the context is available from the moment an instance exists, which matters when descendants have eager computed properties that read it during their own construction.
provideContext is a reserved constructor config key holding an object of context configurations, keyed by context key. It takes the same forms _context does.
const application = _Application({
provideContext: {
currentUser: {
host: session,
propertyName: 'user'
},
requestHeaders: {
getFunction: '_getRequestHeaders'
},
[_serviceKey]: {
value: service
}
}
});This runs before the instance initializes, so the context is in place before its own initFunctions and eager computed properties evaluate, and before any descendant can be constructed. That ordering is the reason to prefer it over calling provideContext() afterward. See Provide Before Descendants Are Constructed.
An object literal can only be keyed by a string or symbol. Pass a Map when the key is anything else.
const application = _Application({
provideContext: new Map([[
someObjectKey,
{
value: 1
}
]])
});An invalid configuration throws from the constructor rather than publishing initializeError.
provideContext() takes the same configuration in an object, plus the key.
// A live reference to one of this object's state or computed properties.
application.provideContext({
key: 'theme',
propertyName: 'themeName'
});
// A function to call on access. Accepts a function, or a method name.
application.provideContext({
getFunction: '_getRequestHeaders',
key: 'requestHeaders'
});
// A fixed value.
application.provideContext({
key: 'version',
value: '1.0.0'
});Exactly one of getFunction, propertyName, or value must be present. Declaring none of them, or more than one, throws.
Because value is its own configuration key, providing a function as a value is unambiguous. It is stored and returned as-is, never called.
application.provideContext({
key: 'formatCurrency',
value: amount => `$${amount.toFixed(2)}`
});
console.log(typeof button.getContext({
key: 'formatCurrency'
})); // functionContext provided through provideContext() takes precedence over a declaration of the same key in _context on the same object. revokeContext() removes the runtime context and the class declaration becomes visible again. Continuing with the _Application class above, which declares version in _context:
application.provideContext({
key: 'version',
value: '2.0.0'
});
console.log(application.getContext({
key: 'version'
})); // 2.0.0
application.revokeContext({
key: 'version'
});
console.log(application.getContext({
key: 'version'
})); // 1.0.0provideContext() returns a handle object. The handle carries the key it was provided for, a revoked flag, a revoke() method, and Symbol.dispose, which means a context can be bound to a block with using and cleaned up even if the block throws.
{
using theme = application.provideContext({
key: 'theme',
value: 'high-contrast'
});
renderPreview(application); // sees high-contrast
} // revoked hereA handle only revokes the context it provided. Providing the same key again replaces it and hands ownership to the newer handle, so revoking a stale one leaves the current context alone.
const first = application.provideContext({
key: 'theme',
value: 'light'
}),
second = application.provideContext({
key: 'theme',
value: 'dark'
});
first.revoke();
console.log(application.getContext({
key: 'theme'
})); // dark, not the _context declarationRevoking twice does nothing the second time, and revokeContext() remains available for revoking by key without holding a handle.
Providing context on a destroyed instance is a no-op. The call still returns a handle, already revoked, so a using declaration on a destroyed object is safe.
host changes what propertyName is read from and what getFunction is called on. It defaults to the providing object.
application.provideContext({
host: session,
key: 'currentUser',
propertyName: 'user'
});The context is still provided by application and still resolved by position in the tree, but the value comes from session. This is useful for exposing a collaborator's state without making that collaborator part of the tree.
Keys are stored in a Map, so a runtime key can be any value, including symbols and objects. Keys declared in _context are property keys, so they can be strings or symbols.
String keys are convenient but two packages that both provide 'config' will collide, and the nearer one wins.
// keys.js
export const configKey = Symbol('config');getContext() walks from the object itself outward through its ancestors and returns the value from the first one that provides the key.
const theme = button.getContext({
key: 'theme'
});An object sees the context it provides itself. Resolution starts at this, and continues through this.parent.
If no object in the ancestry provides the key, getContext() returns defaultValue, or undefined when no default is given.
const theme = button.getContext({
defaultValue: 'light',
key: 'theme'
});defaultValue is only used when nothing provides the key. A provider that supplies undefined is still a provider, and undefined is returned.
if (button.hasContext({
key: 'theme'
})) {
// something in the ancestry provides it
}Use this to tell "nothing provides this" apart from "something provides this and its value is undefined".
Returns the object that would resolve the key, or null. Useful for debugging and for subscribing directly to a provider.
const provider = button.getContextProvider({
key: 'theme'
});
console.log(`theme comes from ${provider}`);isotropic-state tracks computed property dependencies across object boundaries. Context resolution is built out of ordinary property reads, so that tracking flows through it without any additional machinery.
The recommended way to consume context is to mirror it into a computed property:
const _Widget = _make('Widget', _StateContext, {}, {
_computed: {
theme () {
return this.getContext({
key: 'theme'
});
}
}
});That single declaration gives the widget three things:
widget.themereads the current valuewidget.themeChangefires when the value changes, no matter how far up the tree the change happened- Any other computed property that reads
widget.themerecomputes too
Recomputation is triggered by:
- A change to a provided property.
getContext()reads the property from the provider, which records the dependency. - A change to any state property read by a provided
getFunction. The function runs inside the computation, so its reads are tracked as well. - A change to the ancestry. Walking from the consumer to the provider reads the
parentproperty of every object along the way, so re-parenting anything on that path invalidates the computed value.
const rootA = _Application({
themeName: 'light'
}),
rootB = _Application({
themeName: 'dark'
}),
widget = _Widget({
parent: rootA
});
console.log(widget.theme); // light
widget.parent = rootB;
console.log(widget.theme); // darkA value context is a plain reference. Nothing observes it, and re-providing the key does not invalidate computed properties that already read it. Calling provideContext() or revokeContext() after a descendant has computed a value does not invalidate that descendant either, even when the change means a nearer object now provides the key.
const _Snapshot = _make('Snapshot', _StateContext, {}, {
_computed: {
snapshot () {
return this.getContext({
key: 'snapshot'
});
}
}
});
{
const application = _Application();
application.provideContext({
key: 'snapshot',
value: 1
});
const widget = _Snapshot({
parent: application
});
console.log(widget.snapshot); // 1
application.provideContext({
key: 'snapshot',
value: 2
});
console.log(widget.snapshot); // still 1
console.log(widget.getContext({
key: 'snapshot'
})); // 2
}This is deliberate. A value context facilitates a reference to something. It does not promise to watch it. When you want the consumer to follow changes, provide a property or a function instead, and let the value change rather than the context.
If you do need to re-provide a value after the fact, force dependents to recompute:
widget.snapshot = _Widget.recompute;Eager computed properties compute during initialization. A descendant constructed before its provider has provided will cache the unresolved value.
const application = _Application(),
widget = _Widget({
parent: application
});
// Too late for widget's eager computed property.
application.provideContext({
key: 'lateKey',
value: 'lateValue'
});There are three ways to provide context early:
- Declare it in
_context. A class declaration always exists before any instance does, so nothing can be constructed too soon to see it. - Pass it to the constructor. The
provideContextconfig key is applied before the instance initializes, which is before its own eager computed properties evaluate and before it can be anyone's parent. This is the answer when the context depends on runtime information.
const application = _Application({
provideContext: {
lateKey: {
value: 'lateValue'
}
}
}),
widget = _Widget({
parent: application
});
// widget resolves it during its own construction.- Call
provideContext()from aninitFunctionor_initializeon the provider, before descendants are built.
Marking the consumer's computed property lazy also defers its first computation until something reads it, which sidesteps the ordering question rather than answering it.
Beyond the keys inherited from isotropic-state, isotropic-initializable, and isotropic-pubsub, one key is reserved here.
| Option | Type | Description |
|---|---|---|
provideContext |
Object or Map | Context configurations keyed by context key, applied before initialization. Values take the same forms as _context. Use a Map for keys that are not strings or symbols. |
parent is an ordinary state property, so it is supplied by name like any other.
const widget = _Widget({
parent: application,
provideContext: {
scope: {
value: 'panel'
}
},
subscribe: {
action: handleAction
}
});| Option | Type | Description |
|---|---|---|
getFunction |
Function, String, or Symbol | A function to call on access, or the name of a method on the host. Its return value is the context value. |
host |
Object | The object propertyName is read from and getFunction is called on. Defaults to the providing object. |
propertyName |
String or Symbol | A property of the host, read on each access. |
value |
Any | A fixed value, returned as-is. |
Exactly one of getFunction, propertyName, or value is required.
Only available in _context, where the declaration is a class-level literal.
| Written As | Equivalent To |
|---|---|
key: 'propertyName' |
key: { propertyName: 'propertyName' } |
key: someSymbol |
key: { propertyName: someSymbol } |
key () { ... } |
key: { getFunction () { ... } } |
A string in the shorthand form is a property name. A method name is only understood in the long form under getFunction.
parent: The parent object, ornull. PublishesparentChange.
root: The furthest ancestor, or the instance itself when it has no parent. Nevernullon a live instance. Eager, so it publishesrootChange.
getContext({ defaultValue, key }): The value forkeyfrom the nearest provider, ordefaultValuegetContextProvider({ key }): The object that provideskey, ornullhasContext({ key }): Whether anything in the ancestry provideskeyprovideContext({ getFunction, host, key, propertyName, value }): Provide context forkey. Returns a context handleremoveDistributor(distributor): As inherited fromisotropic-pubsub, and additionally setsparenttonullwhen the removed distributor is the current parent. ReturnsthisrevokeContext({ key }): Remove a runtime context forkey. ReturnsthisstateContextChain(): A generator yielding this instance followed by each parent up to the root
Everything else from isotropic-state, isotropic-initializable, and isotropic-pubsub is inherited unchanged.
The object provideContext() returns.
key: The context key this handle was provided forrevoke(): Revoke this handle's context, unless it has already been revoked or replaced. Returns the handlerevoked: Whetherrevoke()has run, or the instance was destroyed when the context was provided[Symbol.dispose](): Callsrevoke(), so the handle works withusing
_context: A property chain of context declarations, inherited and overridable by subclasses
stateContextChain(stateContext): A generator yielding the given StateContext instance followed by each parent up to the root
StateContext is a subclass of State, and it also works as a mixin for a class that extends State by another route.
const _Model = _make('Model', _State, [
_StateContext
], {}, {
_state: {
id: {
initFunction: () => null
}
}
});The mixed class gets the parent property, the context methods, and its own _context chain. Instances are recognized as valid parents by other objects in the tree.
import _make from 'isotropic-make';
import _StateContext from 'isotropic-state-context';
const _Component = _make('Component', _StateContext, {
_initialize () {
this._subscribe('on', 'action', event => {
if (event.publisher !== this) {
console.log(`${this.id} saw an action from ${event.publisher.id}`);
}
});
}
}, {
_state: {
id: {
initFunction: () => ''
}
}
});
{
const application = _Component({
id: 'application'
}),
header = _Component({
id: 'header',
parent: application
}),
menu = _Component({
id: 'menu',
parent: header
});
menu.publish('action', {
name: 'logout'
});
// header saw an action from menu
// application saw an action from menu
}An object provides a collaborator. Descendants ask for it by name and never see how it was constructed or where it lives.
const _serviceKey = Symbol('service'),
_Application = _make('Application', _StateContext, {}, {
_context: {
[_serviceKey]: '_service'
},
_state: {
_service: {
initFunction () {
return {
load: id => `record ${id}`
};
}
}
}
}),
_Panel = _make('Panel', _StateContext, {
load (id) {
return this.getContext({
key: _serviceKey
}).load(id);
}
});
{
const application = _Application(),
panel = _Panel({
parent: _Panel({
parent: application
})
});
console.log(panel.load(7)); // record 7
}Substituting a test double is a matter of providing a different value at the root.
const application = _Application({
provideContext: {
[_serviceKey]: {
value: {
load: id => `stub ${id}`
}
}
}
});
console.log(_Panel({
parent: application
}).load(7)); // stub 7Any object can shadow a key for everything below it, without affecting its siblings.
const application = _Application({
themeName: 'light'
}),
sidebar = _Widget({
parent: application
}),
modal = _Widget({
parent: application
}),
sidebarButton = _Widget({
parent: sidebar
}),
modalButton = _Widget({
parent: modal
});
modal.provideContext({
key: 'theme',
value: 'high-contrast'
});
console.log(sidebarButton.getContext({
key: 'theme'
})); // light
console.log(modalButton.getContext({
key: 'theme'
})); // high-contrastReach for _context first. It reads as part of the class's interface, it exists before any instance does, and the configuration objects are shared rather than rebuilt per instance. When the context genuinely depends on runtime information, pass it to the constructor. Use provideContext() for context that appears after construction, or that needs to be revoked.
Calling getContext() directly in a method is fine for one-off reads, but a computed property gives you caching, change events, and participation in the dependency graph for the same amount of code.
propertyName and getFunction contexts stay correct as the provider changes. A value context is a snapshot. Prefer the former unless the thing really is immutable.
A string key is a global name in the tree. Export a symbol from a module and the collision becomes impossible.
An ancestor sees every event from its whole subtree. When a subscription is only interested in the object's own events, compare event.publisher to the object.
A consumer resolves the wrong value. Something nearer than you expect provides the key. getContextProvider() tells you which object it is.
console.log(widget.getContextProvider({
key: 'theme'
}));A consumer resolves undefined. Either nothing provides the key or the provider is not in the ancestry. hasContext() distinguishes the two, and Array.from(widget.stateContextChain()) shows the path that was searched.
A computed property does not update. Check that the context is a propertyName or getFunction rather than a value, and that the provider provided before the consumer first computed. See Reactivity.
An event fires more than expected. Distribution is transitive, so ancestors see the whole subtree. Compare event.publisher to the subscriber.
parent became null on its own. Something detached it. The parent was destroyed, or removeDistributor was called with it. Subscribe to parentChange to catch the moment it happens.
root is the object itself when you expected an ancestor. The object is detached. Either parent was never assigned or something detached it, and an object with no parent is its own root by definition.
A context disappeared unexpectedly. A handle was revoked, or disposed at the end of a block it was scoped to. getContextProvider() confirms nothing provides the key any more.
- isotropic-state: The base class, supplying observable properties, computed properties, and cross-object dependency tracking
- isotropic-pubsub: Supplies the distributor mechanism that event bubbling is built on
- isotropic-make: Constructs the class and supports using it as a mixin
- isotropic-instance-of: Recognizes state objects for parent validation, including through mixins
- isotropic-error: Produces structured errors for invalid parents, circular ancestry, and invalid context configurations
Please refer to CONTRIBUTING.md for information on how to contribute.
Please refer to the issue tracker.
BSD-3-Clause