Skip to content

Latest commit

 

History

24 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

isotropic-state

npm version License

A reactive state management module built on the isotropic ecosystem, providing observable properties with validation, transformation, computed properties, and batched change notifications.

Why Use This?

  • 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 equalityFunction when 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

Installation

npm install isotropic-state

Basic Usage

import _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 Configuration

State properties are defined in the static _state object. Each property can have the following configuration options:

Basic Options

  • changeEventName (String): Custom event name (default: ${propertyName}Change)
  • initFunction (Function or String): Default value function or method name
  • internalPropertyName (String): Internal storage name (default: _state_${propertyName})

Validation & Transformation

  • 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 retrieving
  • setFunction (Function or String): Transforms values before storing
  • validateFunction (Function or String): Validates the incoming value before it is transformed. Return true to allow
  • validateInternalFunction (Function or String): Validates the stored value after it is transformed. Return true to allow

Each of these receives the assignment's stateConfig object as its last argument. See Explicit get and set.

Access Control

  • readOnly (Boolean or 'setOnce'): Makes property read-only or settable only once
  • readOnlySetBehavior ('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)

Event Configuration

  • allowPublicSubscription (Boolean): Allow public subscription to property events
  • allowPublicUnsubscription (Boolean): Allow public unsubscription from property events

The Assignment Pipeline

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:

  1. validateFunction decides whether the incoming value is acceptable. Returning anything other than true abandons the assignment silently.
  2. setFunction transforms the accepted value into the representation that actually gets stored.
  3. validateInternalFunction decides whether the stored representation is acceptable. Returning anything other than true abandons the assignment silently.
  4. equalityFunction (or === by default) compares the stored representation with the current one. Equal values abandon the assignment.
  5. A change event is published. Its complete stage 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:

initFunction produces 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
}

Change Events During Initialization

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.

Explicit get and set

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, forwarding stateConfig to getFunction.
  • set(propertyName, value, stateConfig) assigns one value, forwarding stateConfig to validateFunction, setFunction, validateInternalFunction, and equalityFunction.
  • set(values, stateConfig) assigns several at once from an object, with the same stateConfig applied 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 user

get 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.

stateConfig in the Constructor

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.

Writable Computed Properties

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.

Resetting

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 stateConfig

When 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: true property is not reset, and neither is a setOnce property, spent or not. Resetting a setOnce property would publish a change event and therefore spend its one set on a value nobody set.
  • An asynchronous initFunction makes reset asynchronous. It returns this when every value was produced synchronously and a promise resolving to this when any was not.

Custom Change Detection

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

Computed properties automatically recalculate when their dependencies change. They are defined in the static _computed object.

Simple Syntax

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)
}

Computed Property Features

  • 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

Computed Property Configuration

When you need more control, use the full configuration object:

  • allowPublicSubscription (Boolean): Allow public subscription to change events
  • allowPublicUnsubscription (Boolean): Allow public unsubscription from change events
  • changeEventName (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-only
  • lazy (Boolean): If true, only computes on access and doesn't publish change events (default: false)

Examples

Validation and Transformation

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
}

Read-Only Properties

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.

Complex Computed Properties

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
}

Lazy Computed Properties

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);
}

The change Event

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
}

auto

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.

individual

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.

manual

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 change event for everything assigned during construction.
  • reset of more than one property, whether an iterable of names or the whole instance.
  • set with an object, which publishes one change event for the whole group.

off

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.

Force Updates

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;
}

Recomputing Computed Properties

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
}

Manual Batch Control

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.

Scoped Batches

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 here

This 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.

Overlapping Batches

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.

Set Batches Automatically

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' event

It 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.

Cross-Object Dependencies

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
}

Reassigning References

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)
}

Cleaning Up Cross-Object Dependencies

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;
}

Event Lifecycle

Each property change goes through the standard isotropic-pubsub event lifecycle:

  1. before: Validate or prevent the change
  2. on: Main change handling
  3. complete: Actually set the value (handled internally)
  4. 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...');
});

Preventing a Change

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.

Awaiting Changes

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.

Awaiting Initialization

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.

Asynchronous State Property Initialization

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 undefined until its promise resolves, and reading the property during that window returns undefined rather 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 initFunction value 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 _initializeError is 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.

Best Practices

1. Keep Computed Properties Pure

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}`;
    }
}

2. Use Lazy Computation for Expensive Operations

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
    }
}

Debugging Guide

Understanding Why Properties Aren't Updating

  1. Check validation: If a validateFunction returns anything other than true, the change is silently ignored. This applies to constructor values too
  2. Check read-only status: Read-only properties won't update after initialization, and a setOnce property is already spent if the constructor supplied a value for it
  3. 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 === oldValue was false and otherwise means the equalityFunction returned a truthy value
  4. Check what the setFunction returned: The comparison happens on the stored value, after setFunction runs, not on the value you assigned
  5. Check internal validation: A validateInternalFunction rejects the transformed value, so a valid input can still be discarded
  6. Check event prevention: A subscriber might be preventing the change

Debugging Circular Dependencies

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: {}
}

Performance Profiling

To identify performance issues:

  1. Monitor compute frequency: Add temporary logging to computed properties
  2. Check dependency chains: Deep chains can cause cascading updates
  3. Use lazy computation: For expensive computed properties that aren't always needed

Using Browser DevTools

// 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
        ]);
    }
});

Handling Validation Errors

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
}

Advanced Patterns

State Inheritance

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)'
}

Overriding Change Event Handlers

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: {}
    }
});

Customizing Dependency Tracking

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.

Type Safety Enhancement

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
}

API Reference

Constructor Options

  • batchChangeMode (String): 'auto', 'individual', 'manual', or 'off' (default). Controls whether and how the aggregate change event is published. An unrecognized value throws from _initialize
  • stateConfig (Object): Assignment context applied to every state property
  • stateConfigByPropertyName (Object): Assignment context keyed by property name, shallow-merged over stateConfig
  • 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 from isotropic-pubsub. distributors and subscribe are applied before initialization, so they can observe change events published during initialization

Instance Methods

  • batchChanges(): Open a batch of changes, or join the one already in progress, and return a handle. The handle has a flush() method that publishes the batch once every outstanding handle has been flushed, and a Symbol.dispose that calls it. An unflushed batch publishes at the end of the current turn of the event loop
  • destroy(): Detach the object from all of its cross-object dependency relationships (in both directions) and tear down its event system
  • get(propertyName, stateConfig): Read a state or computed property, forwarding stateConfig to the property's getFunction. Throws for an undeclared property
  • reset(propertyName, stateConfig): Return a state property to its initFunction value, or undefined if it has none, forwarding stateConfig to the initFunction. Read-only properties are skipped. Given the name of a computed property, marks it for recomputation instead. Returns this, or a promise resolving to this if an initFunction returned one
  • reset(propertyNames, stateConfig): Reset the properties named by an iterable, batched into a single change event
  • reset(stateConfig): Reset every state property, batched into a single change event
  • set(propertyName, value, stateConfig): Assign a state property or a writable computed property, forwarding stateConfig through the pipeline. Throws for an undeclared property. Returns this
  • set(values, stateConfig): Assign several properties from an object, applying the same stateConfig to each, batched into a single change event. Returns this

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.

Instance Methods (protected)

  • _assignComputedValue({ propertyName, stateConfig, value }): Invokes a computed property's setFunction, or throws if it has none
  • _assignValue({ propertyName, stateConfig, value }): Runs a value through the assignment pipeline and returns this. 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 a readOnly property, 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's change event, if that batch is still the one in progress and it has changes to report. Both flush() and the scheduled flush go through here
  • _resetValue({ propertyName, stateConfig, value }): Stores a reset value and publishes its change event, subject to the property's equalityFunction
  • _valuesAreEqual({ equalityFunction, newValue, oldValue, stateConfig }): Applies the property's equalityFunction, or === when there is none. Override to change change-detection for every property at once

Static Properties

  • forceChangeEvent (Symbol): Force a change event for the current value
  • recompute (Symbol): Force recomputation of a computed property

State Configuration Reference

{
    _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 Configuration Reference

{
    _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
        }
    }
}

Integration with Other Isotropic Modules

isotropic-state integrates seamlessly with:

  • isotropic-error: Produces structured errors (circular dependencies, compute failures, read-only writes)
  • isotropic-for-in: Walks the inherited _state and _computed property 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

About

A utility to manage observable state changes

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages