A reactive state management module built on the isotropic ecosystem, providing observable properties with validation, transformation, computed properties, and batched change notifications.
- Reactive Properties: Automatically publishes events when state changes
- Computed Properties: Automatically recalculate values when dependencies change
- Validation & Transformation: Built-in support for validating and transforming values
- Custom Change Detection: Supply an
equalityFunctionwhen strict equality is not the right test - Batched Updates: Optionally batch multiple changes in the same turn of the event loop
- Flexible Configuration: Extensive options for customizing property behavior
- Event-Based Architecture: Leverages isotropic-pubsub for powerful event handling
- Read-Only Support: Properties can be read-only or set-once
- Force Updates: Manually trigger change events for object mutations
- Efficient Caching: Computed properties are cached and only recalculate when needed
npm install isotropic-stateimport _make from 'isotropic-make';
import _State from 'isotropic-state';
// Create a component with state
const _Counter = _make('Counter', _State, {
increment () {
this.count += 1;
return this;
}
}, {
_state: {
count: {
initFunction: () => 0
}
}
});
{
// Create instance and listen for changes
const counter = _Counter();
counter.on('countChange', event => {
console.log(`Count changed from ${event.data.oldValue} to ${event.data.newValue}`);
});
counter.increment(); // Count changed from 0 to 1
}State properties are defined in the static _state object. Each property can have the following configuration options:
changeEventName(String): Custom event name (default:${propertyName}Change)initFunction(Function or String): Default value function or method nameinternalPropertyName(String): Internal storage name (default:_state_${propertyName})
equalityFunction(Function or String): Compares the incoming stored value with the current one. Return a truthy value to report them equal, which suppresses the change. Defaults to===getFunction(Function or String): Transforms values when retrievingsetFunction(Function or String): Transforms values before storingvalidateFunction(Function or String): Validates the incoming value before it is transformed. Returntrueto allowvalidateInternalFunction(Function or String): Validates the stored value after it is transformed. Returntrueto allow
Each of these receives the assignment's stateConfig object as its last argument. See Explicit get and set.
readOnly(Boolean or 'setOnce'): Makes property read-only or settable only oncereadOnlySetBehavior('ignore', 'throw', 'event'): How to handle writes to read-only properties (default: 'ignore')readOnlySetEventName(String): Event name for read-only write attempts (default:${propertyName}ReadOnlySet)
allowPublicSubscription(Boolean): Allow public subscription to property eventsallowPublicUnsubscription(Boolean): Allow public unsubscription from property events
Every value that reaches a state property travels the same path, whether it arrives through the constructor, through a later assignment, or from a subclass calling _assignValue directly:
validateFunctiondecides whether the incoming value is acceptable. Returning anything other thantrueabandons the assignment silently.setFunctiontransforms the accepted value into the representation that actually gets stored.validateInternalFunctiondecides whether the stored representation is acceptable. Returning anything other thantrueabandons the assignment silently.equalityFunction(or===by default) compares the stored representation with the current one. Equal values abandon the assignment.- A change event is published. Its
completestage writes the internal property and registers the change with the current batch.
A rejected value is discarded silently, with no error and no event. isotropic-state is a base class and has no idea why a value failed your validation, so any error it invented would be less useful than one you throw yourself. If a rejected value should be loud, throw from your own validateFunction, where you have the context to say something worth reading. See Handling Validation Errors.
Both validation steps are worth having because they answer different questions. validateFunction guards the interface: is this something a caller is allowed to hand us? validateInternalFunction guards the invariant: is the result something this object is allowed to hold? A property that accepts a duration as a string and stores milliseconds wants the first to reject a malformed string and the second to reject a negative or absurdly large number, and neither check can be written in the other's terms.
There is one deliberate exception, and it is the distinction that makes the rest of this section predictable:
initFunctionproduces the stored representation.
An initFunction is written by the class author, so its result is already in whatever form the property stores. It is written straight to the internal property: no validation, no transformation, no change event, and it does not consume a setOnce. A value passed to the constructor comes from outside the class and gets the full pipeline.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
// The API and database speak centimeters. The application speaks meters.
const _Measurement = _make('Measurement', _State, {}, {
_state: {
length: {
getFunction: centimeters => centimeters / 100,
initFunction: () => 0, // already in centimeters, stored as-is
setFunction: meters => meters * 100,
validateFunction: meters => typeof meters === 'number' && meters >= 0,
validateInternalFunction: centimeters => centimeters <= 100000
}
}
});
{
const measurement = _Measurement({
length: 12 // meters, because the constructor takes external input
});
console.log(measurement.length); // 12
console.log(measurement._state_length); // 1200
measurement.length = -1; // Validation fails, no change
console.log(measurement.length); // 12
}Because constructor values run the full pipeline, they publish change events during initialization, with oldValue undefined.
These events are published before the constructor returns, so an ordinary subscription added afterward cannot see them. There are two ways to be listening in time, both of which are isotropic-pubsub features available on any state object.
The first is the subscribe constructor property, which registers subscriptions before initialization begins:
{
const measurement = _Measurement({
length: 12,
subscribe: {
lengthChange: {
callbackFunction ({
data: {
newValue,
oldValue
}
}) {
console.log(oldValue, newValue); // undefined, 1200
},
stageName: 'after'
}
}
});
measurement.length = 24; // 1200, 2400
}Note that subscribe is keyed by event name. Each value is either a callback function or a configuration object, and the default stageName is 'on'.
The second is the distributors constructor property, which routes this object's events through another pubsub object. This is the better fit when something outside the object needs to watch many state objects:
import _Pubsub from 'isotropic-pubsub';
{
const auditLog = _Pubsub();
auditLog.on('lengthChange', ({
data: {
newValue,
propertyName
},
publisher
}) => {
console.log(`${propertyName} = ${newValue}${publisher.initializing ? ' (initial)' : ''}`);
});
const measurement = _Measurement({
distributors: auditLog,
length: 12
}); // length = 1200 (initial)
measurement.length = 24; // length = 2400
}event.publisher.initializing can be used to check if this event was published during initialization.
Whether initialization publishes an aggregate change event depends on batchChangeMode. In 'auto' and 'manual' modes, everything assigned during construction accumulates into one batch, published once initialization finishes, after all the individual property change events. In 'individual' mode each initialized property publishes its own. In 'off' mode, none are published.
The aggregate change event reports exactly the properties that published an individual change event, which means constructor values and the first computation of eager computed properties, but not values that came from an initFunction:
{
const _Sized = _make('Sized', _State, {}, {
_computed: {
area () {
return this.width * this.height;
}
},
_state: {
height: {},
label: {
initFunction: () => 'untitled'
},
width: {}
}
});
_Sized({
batchChangeMode: 'manual',
height: 4,
subscribe: {
change: {
callbackFunction ({
data: {
newValue,
propertyNameSet
}
}) {
console.log(Array.from(propertyNameSet)); // ['height', 'width', 'area']
console.log(newValue.area); // 12
},
stageName: 'after'
}
},
width: 3
});
}Note that label is absent: an initFunction declares a default rather than assigning a value, so it is not a change and does not appear. If nothing was assigned at all, no change event is published.
The initialization batch closes when initialization finishes, so an assignment made immediately after the constructor returns is a fresh change and behaves normally rather than joining the initialization batch.
Property accessors are the ergonomic default, but they can only carry a value. When a value needs context to be interpreted, use get and set, which take a stateConfig object and pass it through to every function in the pipeline:
get(propertyName, stateConfig)returns the value, forwardingstateConfigtogetFunction.set(propertyName, value, stateConfig)assigns one value, forwardingstateConfigtovalidateFunction,setFunction,validateInternalFunction, andequalityFunction.set(values, stateConfig)assigns several at once from an object, with the samestateConfigapplied to each.
stateConfig is whatever you want it to be. isotropic-state never inspects it, it only hands it to your functions, so its shape is a contract between a property's configuration and the code that assigns to it.
The motivating case is a property with more than two representations. getFunction and setFunction already convert between the external representation and the stored one. stateConfig lets a caller name a different external representation:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Measurement = _make('Measurement', _State, {}, {
_state: {
length: {
// Stored in centimeters. Meters unless the caller says otherwise.
getFunction: (centimeters, {
unit = 'm'
} = {}) => unit === 'cm' ?
centimeters :
centimeters / 100,
initFunction: () => 0,
setFunction: (value, {
unit = 'm'
} = {}) => unit === 'cm' ?
value :
value * 100,
validateFunction: value => typeof value === 'number' && value >= 0
}
}
});
{
const measurement = _Measurement();
measurement.length = 3; // meters, the default representation
measurement.set('length', 250, {
unit: 'cm'
});
console.log(measurement.length); // 2.5
console.log(measurement.get('length', {
unit: 'cm'
})); // 250
}The same mechanism covers anything where context changes the interpretation rather than the value: a locale for a formatted string, a format for a date, a source marking where a value came from so a subscriber can tell a user edit from a server push.
stateConfig is included in the change event data, so a before or after subscriber can see the context a change was made with.
measurement.after('lengthChange', ({
data: {
newValue,
stateConfig
}
}) => {
console.log(newValue, stateConfig?.source);
});
measurement.set('length', 5, {
source: 'user'
}); // 500 userget and set also work on computed properties, and both throw if the property was never declared, which makes a typo an error rather than a silent no-op.
Construction takes a stateConfig too. It applies to every state property, and stateConfigByPropertyName overrides it per property. The two are shallow-merged, with the per-property entry winning:
{
const measurement = _Measurement({
length: 250,
stateConfig: {
unit: 'cm'
}
});
console.log(measurement.length); // 2.5
}
{
const measurement = _Measurement({
height: 6,
length: 250,
stateConfig: {
unit: 'cm'
},
stateConfigByPropertyName: {
height: {
unit: 'm'
}
}
});
}stateConfig and stateConfigByPropertyName are reserved constructor properties, alongside batchChangeMode and the distributors, pubsub, and subscribe keys inherited from isotropic-pubsub. A state property cannot use one of those names.
The resolved stateConfig is passed to initFunction as well, so a default can depend on the same context as a supplied value.
A computed property is read-only unless you give it a setFunction. The setFunction converts in the opposite direction from the computeFunction and assigns to the state properties the computation reads. This gives an alternate representation a real property of its own, with its own change event that dependent computations can track.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Rectangle = _make('Rectangle', _State, {}, {
_computed: {
area () {
return this.width * this.height;
},
widthInches: {
computeFunction () {
return this.width / 2.54;
},
setFunction (inches) {
this.width = inches * 2.54;
}
}
},
_state: {
height: {
initFunction: () => 10
},
width: {
initFunction: () => 10
}
}
});
{
const rectangle = _Rectangle();
rectangle.after('widthInchesChange', ({
data: {
newValue
}
}) => {
console.log('width is now', newValue, 'inches');
});
rectangle.widthInches = 4; // width is now 4 inches
console.log(rectangle.width); // 10.16
console.log(rectangle.area); // 101.6
}Two things to keep in mind. The setFunction must assign to state properties the computeFunction actually reads, or the write will be discarded the next time the property recomputes. And a computed setFunction should not validate: validation belongs on the underlying state property, so there is one place where the truth about a value lives.
A computed setFunction receives the stateConfig as its second argument, so rectangle.set('widthInches', 4, stateConfig) can forward context down to the state property it assigns.
reset returns a state property to its declared initial value by re-running its initFunction, or to undefined if there is none. Note carefully that this is the declared initial value, not the value the instance was constructed with.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Form = _make('Form', _State, {}, {
_state: {
email: {
initFunction: () => ''
},
name: {
initFunction: () => ''
}
}
});
{
const form = _Form({
email: 'ada@example.com',
name: 'Ada'
});
form.reset('name'); // One property
form.reset([
'name',
'email'
]); // The properties named by an iterable
form.reset(); // Every property
console.log(form.email); // '', not 'ada@example.com'
}Given the name of a computed property, reset marks it for recomputation. It's the same as assigning _State.recompute to it.
The iterable form accepts anything iterable, so an array, a Set, or a generator all work.
Unlike construction, a reset is a transition, so it publishes a change event and dependent computed properties recompute. Like construction, the value comes from an initFunction and is therefore already the stored representation, so it is not validated or transformed.
Calling reset() with no property name resets every state property. Computed properties are not included, because resetting the state properties they read already triggers their recomputation. An iterable may name computed properties explicitly, and each one is marked for recomputation.
reset accepts a stateConfig, which is passed to the initFunction:
form.reset('name', {
locale: 'fr'
}); // One property, with a stateConfig
form.reset([
'name',
'email'
], {
locale: 'fr'
}); // Several properties, with a stateConfig
form.reset({
locale: 'fr'
}); // Every property, with a stateConfigWhen reset operates on more than one property it batches them, exactly as set with an object does: in 'manual' mode the group publishes a single change event, and in the other modes it follows whatever that mode does. Resetting a single named property is a single change and is not batched.
Two behaviors worth knowing:
- Read-only properties are skipped. A
readOnly: trueproperty is not reset, and neither is asetOnceproperty, spent or not. Resetting asetOnceproperty would publish a change event and therefore spend its one set on a value nobody set. - An asynchronous
initFunctionmakesresetasynchronous. It returnsthiswhen every value was produced synchronously and a promise resolving tothiswhen any was not.
By default values are compared with ===, and a change is anything that is not equal. That is the right test for primitives and for object references that are replaced wholesale, but not for values that should be compared by content or by identity field. Supply an equalityFunction to decide for yourself. It receives the incoming stored value and the current stored value, and a truthy return means "no change".
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Record = _make('Record', _State, {}, {
_state: {
selected: {
// Two objects describing the same row are the same selection
equalityFunction: (newValue, oldValue) => newValue?.id === oldValue?.id
}
}
});
{
const record = _Record({
selected: {
id: 1,
label: 'First'
}
});
record.after('selectedChange', () => {
console.log('selection changed');
});
record.selected = {
id: 1,
label: 'First (refetched)'
}; // No change event, and the stored value is left alone
record.selected = {
id: 2,
label: 'Second'
}; // 'selection changed'
}equalityFunction is also available on computed properties, where it decides whether a recomputation counts as a change. This matters for a computation that builds a fresh object or array every time it runs, which is never ===-equal to its predecessor and would otherwise publish a change event on every recompute:
const _Report = _make('Report', _State, {}, {
_computed: {
summary: {
computeFunction () {
return {
count: this.items.length,
total: this.items.reduce((sum, item) => sum + item.value, 0)
};
},
equalityFunction: (newValue, oldValue) => newValue.count === oldValue?.count && newValue.total === oldValue?.total
}
},
_state: {
items: {
initFunction: () => []
}
}
});Computed properties automatically recalculate when their dependencies change. They are defined in the static _computed object.
For computed properties that only need a compute function, you can use the shorthand syntax:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Person = _make('Person', _State, {}, {
_computed: {
fullName () {
return `${this.firstName} ${this.lastName}`.trim();
}
},
_state: {
firstName: {
initFunction: () => ''
},
lastName: {
initFunction: () => ''
}
}
});
{
const person = _Person({
firstName: 'Jane',
lastName: 'Smith'
});
console.log(person.fullName); // 'Jane Smith'
person.firstName = 'John';
console.log(person.fullName); // 'John Smith' (automatically updated)
}- Automatic Dependency Tracking: Dependencies are detected automatically when the getter runs
- Efficient Caching: Values are cached and only recomputed when dependencies change
- Chained Dependencies: Computed properties can depend on other computed properties
- Cross-Object Dependencies: Computed properties can depend on properties of other state objects and are recomputed when those change (see Cross-Object Dependencies)
- Change Events: Computed properties publish change events just like state properties
- Circular Dependency Detection: Throws an error if circular dependencies are detected
When you need more control, use the full configuration object:
allowPublicSubscription(Boolean): Allow public subscription to change eventsallowPublicUnsubscription(Boolean): Allow public unsubscription from change eventschangeEventName(String): Custom event name (default:${propertyName}Change)computeFunction(Function or String): Function that computes the value (required)equalityFunction(Function or String): Compares a freshly computed value with the cached one. Return a truthy value to report them equal, which suppresses the change. Defaults to===internalPropertyName(String): Internal storage name (default:_computed_${propertyName})setFunction(Function or String): Makes the property writable. Converts an assigned value back into assignments on the state properties the computation reads. Without one, the property is read-onlylazy(Boolean): If true, only computes on access and doesn't publish change events (default: false)
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _User = _make('User', _State, {
_normalizeEmail (value) {
return value ?
value.toLowerCase().trim() :
'';
},
_validateEmail (value) {
return !value || value.includes('@');
}
}, {
_state: {
age: {
initFunction: () => 0,
validateFunction: value => value >= 0 && value <= 150
},
email: {
initFunction: () => '',
setFunction: '_normalizeEmail',
validateFunction: '_validateEmail'
}
}
});
{
const user = _User();
user.email = ' JOHN@EXAMPLE.COM '; // Stored as 'john@example.com'
user.email = 'invalid'; // Validation fails, no change
}import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Config = _make('Config', _State, {}, {
_state: {
apiKey: {
readOnly: 'setOnce',
readOnlySetBehavior: 'throw'
},
version: {
initFunction: () => '1.0.0',
readOnly: true,
readOnlySetBehavior: 'event'
}
}
});
{
const config = _Config();
config.on('versionReadOnlySet', event => {
console.log('Attempted to set version to:', event.data.attemptedValue);
});
config.version = '2.0.0'; // Triggers event
config.apiKey = 'secret-key'; // Works
config.apiKey = 'new-key'; // Throws error
}A constructor value counts as the one set for a setOnce property, so the property is already spent when the constructor returns:
{
const config = _Config({
apiKey: 'secret-key'
});
config.apiKey = 'new-key'; // Throws error
}A value produced by an initFunction does not count, because it is the class's own default rather than external input. A setOnce property with an initFunction and no constructor value starts out with its default and remains settable exactly once.
readOnly: true properties can only be populated by the constructor or by an initFunction, and a constructor value for one is validated and transformed like any other external input.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _ShoppingCart = _make('ShoppingCart', _State, {}, {
_computed: {
subtotal () {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
tax () {
return this.subtotal * this.taxRate;
},
total () {
return this.subtotal + this.tax;
}
},
_state: {
items: {
initFunction: () => []
},
taxRate: {
initFunction: () => 0.08
}
}
});
{
const cart = _ShoppingCart();
cart.items = [{
price: 10,
quantity: 2
}, {
price: 5,
quantity: 3
}];
console.log(cart.subtotal); // 35
console.log(cart.tax); // 2.8
console.log(cart.total); // 37.8
// Update tax rate - all dependent computed properties update
cart.taxRate = 0.10;
console.log(cart.tax); // 3.5
console.log(cart.total); // 38.5
}import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _ExpensiveComputation = _make('ExpensiveComputation', _State, {}, {
_computed: {
// This won't compute until accessed and won't publish change events
analysis: {
computeFunction () {
// Expensive computation here
return this.data.reduce((accumulator, item) => {
// Complex analysis
return accumulator;
}, {});
},
lazy: true
}
},
_state: {
data: {
initFunction: () => []
}
}
});
{
const instance = _ExpensiveComputation();
instance.data = getLotsOfGoodData();
// instance.analysis hasn't been computed yet. It will compute when accessed
// The computation will happen now
console.log(instance.analysis);
}Every state and computed property publishes its own change event, and those always fire. Separately, an instance can publish an aggregate change event describing a group of properties that changed together. An instance publishes no aggregate change events at all by default. batchChangeMode opts in, and chooses how the changes are grouped.
| Mode | Aggregate change events |
|---|---|
'off' (default) |
None, unless you open a batch yourself with batchChanges() |
'auto' |
Exactly one per turn of the event loop, covering everything that changed in it |
'individual' |
One per property change |
'manual' |
One per batch you open, plus one for initialization and one per set or reset of multiple properties |
batchChangeMode is not about performance. It is a statement about whether an instance is one that reports changes in aggregate, and at what granularity. Pick the mode that matches how the object is meant to be observed.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _MyDataObject = _make('MyDataObject', _State, {}, {
_state: {
x: {},
y: {},
z: {}
}
});
{
const component = _MyDataObject({
batchChangeMode: 'auto',
x: 0,
y: 0,
z: 0
});
component.on('change', event => {
console.log('Properties changed:', Array.from(event.data.propertyNameSet));
console.log('Old values:', event.data.oldValue);
console.log('New values:', event.data.newValue);
});
// These changes are batched
component.x = 10;
component.y = 20;
component.z = 30;
// One 'change' event for all three properties
}Every change that happens in a turn of the event loop lands in the same batch, and that batch is published at the end of the turn. There is exactly one aggregate change event per turn, and nothing can make one arrive sooner. batchChanges() still works and still returns a handle, but flushing that handle only signals that your part of the turn is finished. It does not publish. set behaves the same way. This is the mode for an observer that redraws or persists once per turn regardless of how many properties moved.
Initialization is its own batch and publishes its own change event.
Every property change publishes its own change event immediately, as a batch of one. Initializing three properties publishes three events. A set of three properties publishes three events. This is the mode for an observer that wants a uniform, per-change stream and does not care about grouping.
Opening a batch explicitly still coalesces: while a batchChanges() handle is open, changes accumulate and one event is published when it flushes. Otherwise batchChanges() would be meaningless in this mode.
Note that this is two full event dispatches per property change, the property's own and the aggregate.
You decide when a change event is published, by opening and flushing batches. A plain assignment outside a batch publishes no aggregate event at all.
Three groupings are batched for you, because in each case the library knows for certain that the properties changed together:
- Initialization, which publishes one
changeevent for everything assigned during construction. resetof more than one property, whether an iterable of names or the whole instance.setwith an object, which publishes onechangeevent for the whole group.
The instance publishes no aggregate change events. Initialization publishes none, and set does not group. Per-property change events are unaffected.
batchChanges() still works, because there is no good reason to disable it. If a specific piece of code in an otherwise quiet object wants to announce a group of changes, it can.
An unrecognized batchChangeMode is an error thrown from _initialize, so it surfaces through initialization error handling.
When the value of a state property is an array or an object, changes to the array items or object's properties do not trigger a change event. Change events are only published when the value of the state property itself gets changed. A change event can be forced by assigning the forceChangeEvent symbol to the state property. The value of the state property won't actually change, but a change event will be published.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _MyState = _make('MyState', _State, {}, {
_state: {
data: {}
}
});
{
const state = _MyState();
state.data = {
items: []
};
// Mutate the object, this doesn't trigger a change event
state.data.items.push('item1');
// Force a change event
state.data = _State.forceChangeEvent;
}Force a computed property to recalculate:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _RandomValue = _make('RandomValue', _State, {}, {
_computed: {
random () {
return Math.random() * this.multiplier;
}
},
_state: {
multiplier: {
initFunction: () => 1
}
}
});
{
const instance = _RandomValue(), // instance.random is computed
values = [];
values.push(instance.random);
values.push(instance.random); // Same as the first value because the computed property is cached
instance.random = _State.recompute; // instance.random is computed again
values.push(instance.random); // Different value
}batchChanges() opens a batch and returns a handle. Changes accumulate until the batch is flushed, at which point a single change event is published for all of them:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _MyComponent = _make('MyComponent', _State, {}, {
_state: {
x: {},
y: {},
z: {}
}
});
{
const component = _MyComponent({
batchChangeMode: 'manual'
});
const batch = component.batchChanges();
component.x = 1;
component.y = 2;
component.z = 3;
batch.flush(); // Single 'change' event for all three, published synchronously
}A batch that is never flushed still publishes, at the end of the current turn of the event loop. flush() is how you publish sooner, and calling it more than once is harmless.
The handle implements Symbol.dispose, so a using declaration flushes the batch when the block ends, whether it ends normally or by throwing:
{
using batch = component.batchChanges();
component.x = 1;
component.y = 2;
component.z = 3;
} // Flushed hereThis is the form to reach for when the changes are confined to a block, since it cannot be skipped by an early return or an exception.
There is only ever one batch in progress. If batchChanges() is called while a batch is already open, the caller gets a second handle to that same batch rather than a nested one, and the batch is not flushed until every outstanding handle has been flushed:
{
const first = component.batchChanges();
component.x = 1;
{
const second = component.batchChanges();
component.y = 2;
second.flush(); // Nothing published; first is still outstanding
}
component.z = 3;
first.flush(); // Single 'change' event for x, y, and z
}Order does not matter. If first flushes before second, the result is the same: the batch is published when the last handle flushes, and it carries every change made since the first handle was opened. Nothing tracks which changes happened during which handle's lifetime, because there is only one batch.
The practical consequence is that a function which opens a batch, makes changes, and flushes its own handle composes correctly whether it is called on its own or from inside a caller's batch. On its own it publishes immediately. Inside a caller's batch it contributes its changes and lets the caller decide when to publish. It never publishes a partial batch out from under its caller.
In 'auto' mode this goes one step further: handles never publish at all. The batch always waits for the end of the turn, because that mode guarantees exactly one aggregate change event per turn. Code written with explicit batches still runs correctly there, it just coalesces more coarsely than it asked for.
In 'manual' mode, set batches when it is given an object, so assigning several properties at once produces one change event:
component.set({
x: 1,
y: 2,
z: 3
}); // Single 'change' eventIt opens and flushes its own handle, which means it follows the rule above: called inside an open batch, it adds to that batch instead of publishing.
In the other modes set does not open a batch of its own. 'auto' already groups the whole turn, 'individual' deliberately reports each property separately, and 'off' publishes nothing.
reset batches the same way whenever it operates on more than one property, whether that is an iterable of names or the whole instance.
Note that batching coalesces the aggregate change event only. The individual xChange events still publish immediately, internal values are written immediately, and dependent computed properties recompute immediately. A batch is a notification-coalescing mechanism, not a transaction, and flushing one never rolls anything back.
Computed properties are not limited to the object they belong to. A computed property can read properties from other state objects, and the dependency is tracked across the object boundary. When a property on one object changes, computed properties on any other object that read it are recomputed automatically.
The idiomatic way to give one state object a reference to another is to store that reference as a state property. This matters because of initialization order: a state object's eager computed properties are evaluated while it initializes, before any reference you assign imperatively afterward would exist. Passing the reference in as a state property guarantees it is available the first time the computed property runs.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Temperature = _make('Temperature', _State, {}, {
_state: {
celsius: {
initFunction: () => 0
}
}
}),
_Display = _make('Display', _State, {}, {
_computed: {
label () {
return `${this.temperature.celsius}°C`;
}
},
_state: {
temperature: {}
}
});
{
const temperature = _Temperature({
celsius: 20
}),
display = _Display({
temperature
});
console.log(display.label); // '20°C'
temperature.celsius = 25;
console.log(display.label); // '25°C' (recomputed across objects)
}Cross-object dependencies chain and combine exactly like same-object dependencies. A single computed property can read from its own state and from several other objects, and a change to any dependency triggers a recompute:
const _Order = _make('Order', _State, {}, {
_state: {
quantity: {
initFunction: () => 1
}
}
}),
_Product = _make('Product', _State, {}, {
_state: {
price: {
initFunction: () => 0
}
}
}),
_LineItem = _make('LineItem', _State, {}, {
_computed: {
total () {
return this.order.quantity * this.product.price;
}
},
_state: {
order: {},
product: {}
}
});
{
const order = _Order({
quantity: 3
}),
product = _Product({
price: 10
}),
lineItem = _LineItem({
order,
product
});
console.log(lineItem.total); // 30
product.price = 12;
console.log(lineItem.total); // 36
order.quantity = 5;
console.log(lineItem.total); // 60
}Because the reference is just a state property, you can reassign it. The computed property re-tracks its dependencies on its next computation: it starts following the new object and stops following the old one.
{
const celsiusA = _Temperature({
celsius: 100
}),
celsiusB = _Temperature({
celsius: 0
}),
display = _Display({
temperature: celsiusA
});
console.log(display.label); // '100°C'
display.temperature = celsiusB;
console.log(display.label); // '0°C' (now follows celsiusB)
celsiusA.celsius = -40;
console.log(display.label); // '0°C' (no longer follows celsiusA)
}Cross-object tracking creates references between objects: a source object holds references to its dependents so it knows what to recompute when it changes. If a dependent is discarded while its source lives on, that reference would keep the dependent from being garbage collected. Calling destroy() on a state object detaches it from every cross-object relationship in both directions. It removes the object from its sources' dependent lists and removes itself as a dependency of anything that read it.
{
const temperature = _Temperature({
celsius: 20
}),
display = _Display({
temperature
});
console.log(display.label); // '20°C'
// The display no longer needs to track the temperature.
display.destroy();
// Updating the temperature no longer attempts to recompute the destroyed display.
temperature.celsius = 25;
}Destroying the source object is equally safe: anything that depended on it simply stops being notified by it.
State inherits [Symbol.dispose] (from isotropic-pubsub, which calls destroy()). A state object can also be managed with a using declaration. When the block exits, the object is destroyed automatically and detached from all of its cross-object relationships:
{
const temperature = _Temperature({
celsius: 20
});
{
using display = _Display({
temperature
});
console.log(display.label); // '20°C'
} // display.destroy() runs here, detaching it from temperature
// Updating the temperature no longer attempts to recompute the destroyed display.
temperature.celsius = 25;
}Each property change goes through the standard isotropic-pubsub event lifecycle:
- before: Validate or prevent the change
- on: Main change handling
- complete: Actually set the value (handled internally)
- after: Post-change reactions
component.before('valueChange', event => {
if (event.data.newValue < 0) {
event.prevent(); // Prevent negative values
}
});
component.after('valueChange', event => {
console.log('Value updated, saving to database...');
});A before subscriber can call event.prevent() to stop a change. Because the complete stage is what writes the internal property, preventing a change means the value is never stored: the property keeps its previous value, no after subscribers run, and the change is not registered with the current batch, so it does not appear in the aggregate change event either.
{
const account = _Account({
balance: 100
});
account.before('balanceChange', event => {
if (event.data.newValue < 0) {
event.prevent();
}
});
account.balance = -50;
console.log(account.balance); // 100, unchanged
}This is a different mechanism from validateFunction:
validateFunction |
event.prevent() |
|
|---|---|---|
| Belongs to | The property's declaration | A subscriber, added at any time |
| Runs | Before setFunction, on the incoming value |
After setFunction, on the value about to be stored |
| Sees | Only the new value and the stateConfig |
The full event, including oldValue and the publisher |
| Good for | Invariants the class itself owns | Policy imposed from outside the class |
Prefer validateFunction for rules that belong to the property. Reach for prevent() when something outside the class needs a veto, such as a form that blocks edits while it is submitting.
Two things to be careful about.
Preventing a change during initialization leaves the property unassigned. Constructor values run the full pipeline, so a before subscriber registered through the subscribe or distributors constructor keys can prevent one. The property is then left holding whatever its initFunction produced, or undefined, and the object finishes constructing anyway. If a value is important enough to reject, rejecting it in validateFunction and failing loudly there is usually better than silently vetoing it at construction.
A subscriber that throws is not the same as one that prevents. prevent() and stopDispatch() are part of the event lifecycle and unwind cleanly. An exception thrown from a subscriber propagates out to whatever made the assignment, which for a computed property means the recomputation is abandoned. The library handles this without corrupting its own state, and the property recomputes normally once the throwing subscriber is gone, but the assignment that triggered it does not complete.
Every property change is an event, so every property change can be awaited. The until method, inherited from isotropic-pubsub, subscribes once and returns a promise that resolves with a snapshot of the change event:
const {
data: {
newValue,
oldValue
}
} = await order.until('statusChange');
console.log(`Status went from ${oldValue} to ${newValue}`);Awaiting the next change is not always what you actually want, though. A filterFunction runs before the subscription's callback function and decides whether a given event counts. An event that gets filtered out never runs the subscription, so the one-time subscription stays in place and the promise goes on waiting for the change you care about:
// Resolves when the status becomes 'shipped', no matter how many
// other status changes happen first
await order.until({
eventName: 'statusChange',
filterFunction: event => event.data.newValue === 'shipped'
});Pass several event names to race several properties, and add a timeout when the change is not guaranteed to happen:
try {
const {
name
} = await order.until({
eventName: [
'errorChange',
'statusChange'
],
subject: 'Order resolution',
timeout: 30000
});
console.log(`Settled by ${name}`);
} catch (error) {
// Error: Order resolution timed out
}Batched changes work the same way. Whenever a change event is published at all, awaiting it resolves once per batch rather than once per property:
const {
data: {
propertyNameSet
}
} = await component.until('change');
console.log('Changed together:', Array.from(propertyNameSet));A published event finishes dispatching entirely before an awaiting function resumes, so an awaited change is already done and the promise has no opportunity to prevent() it. Use a before subscription to participate in a change and until to observe one.
isotropic-state extends isotropic-initializable, so every state object also has untilInitialized(). State properties are populated and eager computed properties are evaluated during initialization, which for a plain _State subclass is synchronous but a subclass could introduce asynchronous initialization. In this case, the instance exists immediately, but its properties are not populated yet:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _RemoteDocument = _make('RemoteDocument', _State, {
async _initialize ({
url
} = {}) {
this.content = await fetchDocument(url);
}
}, {
_state: {
content: {},
url: {}
}
});
{
const document = _RemoteDocument({
url: 'https://example.com/doc'
});
console.log(document.content); // undefined, the fetch is still in flight
await document.untilInitialized();
console.log(document.content); // The loaded document
}untilInitialized() resolves immediately for an instance that is already initialized and waits for one that is not, so the same call is correct either way. Code that consumes a state object never has to know whether that object initializes synchronously, and it stays correct if a subclass later makes _initialize asynchronous.
This matters most for cross-object dependencies. A dependent object's eager computed properties are evaluated while it initializes, so if they read from an object that is still loading, they compute against unpopulated values. Awaiting the dependency first avoids the wasted pass:
await document.untilInitialized();
const view = _DocumentView({
document
});Initialization can also fail. When it does, untilInitialized() rejects, and the class should implement _initializeError to decide what a failure means. See initialization error handling for the details.
A subclass does not have to override _initialize to initialize asynchronously. A state property value is awaited automatically when it is a promise, whether it comes from the constructor or from an initFunction. Initialization is not complete, and eager computed properties are not evaluated, until every one of them has settled.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Session = _make('Session', _State, {}, {
_computed: {
greeting () {
return `Welcome back, ${this.user.name}`;
}
},
_state: {
settings: {
initFunction () {
return fetchDefaultSettings(); // a promise
}
},
user: {}
}
});
{
const session = _Session({
user: fetchUser(userId) // also a promise
});
console.log(session.user); // undefined, both fetches are still in flight
await session.untilInitialized();
console.log(session.greeting); // 'Welcome back, …'
}Several details are worth knowing:
- The promises are started together and awaited together, so two independent fetches overlap rather than running one after the other.
- Each property's internal value is
undefineduntil its promise resolves, and reading the property during that window returnsundefinedrather than a promise. - Once resolved, the value takes its normal path. A resolved constructor value is validated and transformed and publishes a change event; a resolved
initFunctionvalue is stored directly. Nothing about the pipeline changes because the value happened to arrive asynchronously. - If any of the promises reject, initialization fails.
untilInitialized()rejects and_initializeErroris called, exactly as it would be for a rejected_initialize. - The unwrapping is unconditional, so a state property cannot hold a promise as its value. If you need to store a promise, wrap it in an object or a function.
Computed properties should be pure functions without side effects:
// Good
_computed: {
displayName () {
return `${this.firstName} ${this.lastName}`;
}
}
// Bad - has side effects
_computed: {
displayName () {
console.log('Computing display name'); // Side effect!
this.computeCount++; // Modifying state!
return `${this.firstName} ${this.lastName}`;
}
}If a computed property is expensive and not always needed, make it lazy:
_computed: {
expensiveAnalysis: {
computeFunction () {
// Complex calculations here
return performExpensiveAnalysis(this.data);
},
lazy: true
}
}- Check validation: If a validateFunction returns anything other than
true, the change is silently ignored. This applies to constructor values too - Check read-only status: Read-only properties won't update after initialization, and a
setOnceproperty is already spent if the constructor supplied a value for it - Check if value actually changed: Change events only get published when the new stored value differs from the current one, which by default means
newValue === oldValuewas false and otherwise means theequalityFunctionreturned a truthy value - Check what the setFunction returned: The comparison happens on the stored value, after
setFunctionruns, not on the value you assigned - Check internal validation: A
validateInternalFunctionrejects the transformed value, so a valid input can still be discarded - Check event prevention: A subscriber might be preventing the change
If you get a circular dependency error, trace the dependency chain:
// This will cause a circular dependency
_computed: {
a () {
return this.b + 1;
},
b () {
return this.a - 1; // Circular!
}
}
// Fix by breaking the cycle
_computed: {
a () {
return this.baseValue + 1;
},
b () {
return this.baseValue - 1;
}
},
_state: {
baseValue: {}
}To identify performance issues:
- Monitor compute frequency: Add temporary logging to computed properties
- Check dependency chains: Deep chains can cause cascading updates
- Use lazy computation: For expensive computed properties that aren't always needed
// Add debug logging
const _DebugState = _make('DebugState', _State, {
_event_state_change (event) {
console.log('State change:', event.data);
return Reflect.apply(_State.prototype._event_state_change, this, [
event
]);
}
});By default, isotropic-state silently ignores values that fail validation. As a base class it knows only that your function returned something other than true. It does not know what the value was supposed to be, which constraint it violated, or what the caller should do about it. Any error it invented would be vaguer than one you write yourself. Both validateFunction and validateInternalFunction are ordinary functions on your class, so throw from them, or publish your own event, where you have the context to say something worth reading:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _StrictState = _make('StrictState', _State, {
_validateEmail (value) {
const isValid = !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
if (!isValid) {
// Option 1: Publish a validation failure event
this._publish('emailValidationFailed', {
attemptedValue: value,
reason: 'Invalid email format'
});
// Option 2: Throw an error
throw _Error({
details: {
value
},
message: 'Invalid email value'
});
}
return isValid;
}
}, {
_state: {
email: {
validateFunction: '_validateEmail'
}
}
});
{
const instance = _StrictState();
instance.on('emailValidationFailed', event => {
console.error('Email validation failed:', event.data.reason);
});
instance.email = 'invalid-email'; // Triggers validation failure event
}Child classes inherit and can extend parent state and computed properties:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _Animal = _make('Animal', _State, {}, {
_computed: {
description () {
return `${this.name} the ${this.species}`;
}
},
_state: {
name: {
initFunction: () => ''
},
species: {
initFunction: () => ''
}
}
}),
_Dog = _make('Dog', _Animal, {}, {
_computed: {
detailedDescription () {
return `${this.description} (${this.breed})`;
}
},
_state: {
breed: {
initFunction: () => ''
},
goodBoy: {
initFunction: () => true,
readOnly: true
},
species: { // Override parent
initFunction: () => 'Dog'
}
}
});
{
const dog = _Dog({
breed: 'Golden Retriever',
name: 'Buddy'
});
console.log(dog.description); // 'Buddy the Dog'
console.log(dog.detailedDescription); // 'Buddy the Dog (Golden Retriever)'
}Child classes can override the change event complete methods to add custom behavior:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _TrackedState = _make('TrackedState', _State, {
// Override the computed property change handler
_event_computed_change (event) {
console.log(`Computed property changed: ${event.data.propertyName}`);
// Call parent implementation
return Reflect.apply(_State.prototype._event_computed_change, this, [
event
]);
},
// Override a specific state property change handler
_event_state_userIdChange (event) {
console.log(`User ID changed from ${event.data.oldValue} to ${event.data.newValue}`);
// Custom logic for userId changes
if (event.data.newValue) {
this.loadUserData(event.data.newValue);
}
// Call the generic state change handler
return Reflect.apply(_State.prototype._event_state_change, this, [
event
]);
},
loadUserData (userId) {
// Implementation
}
}, {
_state: {
userId: {}
}
});Dependency tracking is driven by a computation stack stored as a static property on the State class. Whenever a computed property runs, the current { propertyName, state } pair is pushed onto the stack; while it sits on top, every property read is recorded as a dependency of that property. When the computation finishes the pair is popped, restoring the enclosing computation if there is one. Because the stack is a single static structure shared across every class and instance, dependencies are tracked correctly even when computations on different objects are nested inside one another.
The stack and its accessors are static methods, so a child class made with isotropic-make can extend or replace them, for example, to log every computation or to integrate with an external tracking system. The instance getters reach these through this.constructor, so an override on a child class takes effect for that class's instances.
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _LoggedState = _make('LoggedState', _State, {}, {
_pushComputation (computation) {
console.log(`Computing ${computation.propertyName}`);
// Call parent implementation
return Reflect.apply(_State._pushComputation, this, [
computation
]);
}
});The relevant static members are _computationStack (the shared array), _pushComputation({ propertyName, state }), _popComputation(), and _currentComputation(). As with everything in Isotropic, these are conventionally internal (underscore-prefixed) but remain fully accessible and overridable.
While isotropic-state doesn't include built-in type checking, you can easily add it as a child class:
import _make from 'isotropic-make';
import _State from 'isotropic-state';
const _TypedState = _make('TypedState', _State, {
_validateType (value, type) {
switch (type) {
case 'array':
return Array.isArray(value);
case 'boolean':
return typeof value === 'boolean';
case 'function':
return typeof value === 'function';
case 'number':
return typeof value === 'number' && !isNaN(value);
case 'object':
return value !== null && typeof value === 'object' && !Array.isArray(value);
case 'string':
return typeof value === 'string';
default:
if (typeof type === 'function') {
return value instanceof type;
}
return true;
}
}
}, {
_init (...args) {
if (Object.hasOwn(this, '_state')) {
Reflect.ownKeys(this._state).forEach(propertyName => {
const config = this._state[propertyName];
if (config.type) {
const validateFunction = config.validateFunction;
config.validateFunction = function (value) {
// First check type
if (!this._validateType(value, config.type)) {
return false;
}
// Then run original validation if exists
if (validateFunction) {
return typeof validateFunction === 'function' ?
validateFunction.call(this, value) :
this[validateFunction](value);
}
return true;
};
}
});
}
return Reflect.apply(_State._init, this, args);
}
}),
_User = _make('User', _TypedState, {}, {
_state: {
age: {
type: 'number',
validateFunction: value => value >= 0 && value <= 150
},
email: {
type: 'string',
validateFunction: value => !value || value.includes('@')
},
isActive: {
type: 'boolean',
initFunction: () => true
},
metadata: {
type: 'object',
initFunction: () => ({})
},
tags: {
type: 'array',
initFunction: () => []
}
}
});
{
const user = _User();
user.age = "30"; // Type error: age must be number
user.age = 30; // Works
user.tags = {}; // Type error: tags must be array
user.tags = [
'admin',
'user'
]; // Works
}batchChangeMode(String):'auto','individual','manual', or'off'(default). Controls whether and how the aggregatechangeevent is published. An unrecognized value throws from_initializestateConfig(Object): Assignment context applied to every state propertystateConfigByPropertyName(Object): Assignment context keyed by property name, shallow-merged overstateConfig- State property values: Initial values for state properties, treated as external input and run through the full assignment pipeline. A promise is awaited and its resolved value is assigned
distributors,pubsub,subscribe: Inherited fromisotropic-pubsub.distributorsandsubscribeare applied before initialization, so they can observe change events published during initialization
batchChanges(): Open a batch of changes, or join the one already in progress, and return a handle. The handle has aflush()method that publishes the batch once every outstanding handle has been flushed, and aSymbol.disposethat calls it. An unflushed batch publishes at the end of the current turn of the event loopdestroy(): Detach the object from all of its cross-object dependency relationships (in both directions) and tear down its event systemget(propertyName, stateConfig): Read a state or computed property, forwardingstateConfigto the property'sgetFunction. Throws for an undeclared propertyreset(propertyName, stateConfig): Return a state property to itsinitFunctionvalue, orundefinedif it has none, forwardingstateConfigto theinitFunction. Read-only properties are skipped. Given the name of a computed property, marks it for recomputation instead. Returnsthis, or a promise resolving tothisif aninitFunctionreturned onereset(propertyNames, stateConfig): Reset the properties named by an iterable, batched into a singlechangeeventreset(stateConfig): Reset every state property, batched into a singlechangeeventset(propertyName, value, stateConfig): Assign a state property or a writable computed property, forwardingstateConfigthrough the pipeline. Throws for an undeclared property. Returnsthisset(values, stateConfig): Assign several properties from an object, applying the samestateConfigto each, batched into a singlechangeevent. Returnsthis
All of the other isotropic-initializable and isotropic-pubsub instance methods are inherited as well, including after, before, destroy, initialize, on, onceAfter, onceBefore, onceOn, publish, subscribe, until, and untilInitialized.
_assignComputedValue({ propertyName, stateConfig, value }): Invokes a computed property'ssetFunction, or throws if it has none_assignValue({ propertyName, stateConfig, value }): Runs a value through the assignment pipeline and returnsthis. This is what the property setters,set, and the constructor all call. A subclass that needs to assign a value without going through the public setter, such as one populating areadOnlyproperty, should call this rather than writing the internal property directly. Writing the internal property directly skips the change event, which means dependent computed properties are never recomputed_flushChanges({ batch }): Publishes a batch'schangeevent, if that batch is still the one in progress and it has changes to report. Bothflush()and the scheduled flush go through here_resetValue({ propertyName, stateConfig, value }): Stores a reset value and publishes its change event, subject to the property'sequalityFunction_valuesAreEqual({ equalityFunction, newValue, oldValue, stateConfig }): Applies the property'sequalityFunction, or===when there is none. Override to change change-detection for every property at once
forceChangeEvent(Symbol): Force a change event for the current valuerecompute(Symbol): Force recomputation of a computed property
{
_state: {
propertyName: {
allowPublicSubscription: Boolean, // Event subscription control
allowPublicUnsubscription: Boolean, // Event unsubscription control
changeEventCompleteMethodName: String, // Custom method name
changeEventName: String, // Custom event name
equalityFunction: Function | String, // Change detection (default: ===)
getFunction: Function | String, // Transform on get
initFunction: Function | String, // Initial stored value
internalPropertyName: String, // Internal property name
readOnly: Boolean | 'setOnce', // Access control
readOnlySetBehavior: String, // 'ignore' | 'throw' | 'event'
readOnlySetEventName: String, // Event name for violations
setFunction: Function | String, // Transform on set
validateFunction: Function | String, // Validates the incoming value
validateInternalFunction: Function | String // Validates the stored value
}
}
}{
_computed: {
// Shorthand syntax
propertyName () {
return computedValue;
},
// Full configuration syntax
propertyName: {
allowPublicSubscription: Boolean, // Event subscription control
allowPublicUnsubscription: Boolean, // Event unsubscription control
changeEventCleanupMethodName: String, // Custom method name
changeEventCompleteMethodName: String, // Custom method name
changeEventName: String, // Custom event name
computeFunction: Function | String, // Compute function (required)
equalityFunction: Function | String, // Change detection (default: ===)
internalPropertyName: String, // Internal property name
lazy: Boolean, // Lazy evaluation (default: false)
setFunction: Function | String // Makes the property writable
}
}
}isotropic-state integrates seamlessly with:
- isotropic-error: Produces structured errors (circular dependencies, compute failures, read-only writes)
- isotropic-for-in: Walks the inherited
_stateand_computedproperty chains - isotropic-initializable: Provides initialization lifecycle
- isotropic-later: Handles asynchronous batching
- isotropic-make: Creates the constructor functions
- isotropic-property-chainer: Enables state and computed property inheritance
- isotropic-pubsub: Powers the event system