Observable state

white-label-model

Own object, array, or Map state with synchronous change notifications, deep mutation observation, and optional whole-state runtime validation.

Install and requirements

  • Node.js ^22.18.0 or >=24.11.0
  • npm, Yarn, or pnpm for installation
  • Native EventTarget, CustomEvent, AbortController, and AbortSignal.any support
  • Native Proxy support
  • Native Map support when using Map state
import {Model} from 'white-label-model';
const {Model} = require('white-label-model');

State model

Model accepts a plain object, array, or Map as its root state. Mutations are synchronous: when a mutating call returns, accepted state is already visible through get() and listeners have already run.

Object update() operations are shallow merges. Array and Map update() operations address one existing member. clear() keeps the current root shape.

const profile = new Model({name: 'Ada'});
profile.addEventListener('change', event => console.log(event.detail));
profile.update({name: 'Grace'});

Runtime validation

The validator is optional. It receives the complete proposed state before construction or an explicit set/update/push/delete mutation is committed. Return true to accept and false to reject.

TypeScript checks source code at development time; the validator checks actual runtime values. Direct nested writes through get() are observable but do not invoke the whole-state validator. clear() and destroy() intentionally bypass it so cleanup cannot be blocked.

const user = new Model({name: 'Ada'}, value =>
    typeof value === 'object' && value !== null &&
    typeof value.name === 'string'
);

user.set({name: 'Grace'}); // true
user.set({name: 42});      // false

Deep observation

Nested objects, arrays, and Maps are proxied lazily as accessed. Direct writes dispatch change and a mutate CustomEvent whose detail contains operation, path, oldValue, newValue, and the complete current state.

Writes using __proto__, constructor, or prototype are rejected to avoid prototype-pollution paths. A proxy retained from a replaced or cleared root can still mutate that detached object, but it no longer publishes Model events for the current root. If a retained proxy becomes stale only because its object moved to a new array index or remains reachable through another live alias, later mutations publish using a current path; truly detached objects remain silent.

model.addEventListener('mutate', event => {
    console.log(event.detail.path);
});
model.get().user.preferences.theme = 'dark';

Events and mediator integration

Accepted non-silent explicit mutations dispatch change first, followed by the matching operation CustomEvent: set, update, push, delete, or clear. Payloads are carried in CustomEvent.detail.

Model uses the native EventTarget listener contract for local events. Assign name and an EventTarget-compatible mediator to relay local events as model:<name>:<event>; the original payload is carried in CustomEvent.detail at both boundaries. The package does not import Mediator directly.

Serverless and request-scoped state

Create mutable Model instances per request or invocation unless shared process-wide state is explicitly intended. Serverless hosts can reuse a warm process, so module-level mutable state can leak data between requests.

Keep persistence outside Model. Load data from the application-owned database, cache, API, or storage layer, validate it at the trust boundary when needed, apply it to the request Model, then discard or destroy that request-owned state.

Core API

new Model(data?, validator?)

Create an observable root state container.

ParameterTypeDefaultDescription
dataobject | unknown[] | Map—Initial root state; defaults to {}.
validator(state: unknown) => boolean—Optional whole-state runtime acceptance check.
Returns
A Model instance.
Throws / rejects
TypeError when the initial root shape is unsupported or initial validation fails.
Runtime
Browser + Node.js

initialize()

Start the lifecycle for chaining and composition.

Returns
The same Model instance.
Runtime
Browser + Node.js

addEventListener(type, callback, options?)

Subscribe to Model events with native EventTarget semantics; Model payloads are available through CustomEvent.detail.

ParameterTypeDefaultDescription
typestring—Model event name.
callbackEventListener | EventListenerObject—Listener invoked synchronously during dispatch.
optionsboolean | AddEventListenerOptions—Native capture, once, passive, and signal options.
Returns
undefined, matching EventTarget.
Runtime
Browser + Node.js

removeEventListener(type, callback, options?)

Release a Model subscription using EventTarget listener matching semantics.

Returns
undefined, matching EventTarget.
Runtime
Browser + Node.js

dispatchEvent(event)

Synchronously dispatch an application-supplied Event or CustomEvent through the native EventTarget contract.

ParameterTypeDefaultDescription
eventEvent | CustomEvent—Event to dispatch; put payload data in CustomEvent.detail.
Returns
Native EventTarget cancellation result.
Runtime
Browser + Node.js

get() / get(key)

Read the complete observable root or one object property, array index, or Map entry.

ParameterTypeDefaultDescription
keyunknown—Optional property/index/Map key.
Returns
The root state, one resolved value, or undefined when a keyed lookup does not resolve.
Runtime
Browser + Node.js
  • The returned root is observable. Direct nested writes are tracked but do not run the whole-state validator.
  • Proxies retained from a previous root stop publishing Model events after set(), clear(), or destroy() replaces the current root.

set(data, silent?)

Replace the complete root state with another supported root shape.

ParameterTypeDefaultDescription
dataunknown—Replacement root state.
silentbooleanfalseSuppress change/set events when true.
Returns
true when accepted; false for unsupported or validator-rejected data.
Events / side effects
change → set unless silent.
Runtime
Browser + Node.js

update(partial, silent?) / update(key, value, silent?)

Shallow-merge an object root or replace/shallow-merge one existing array or Map member.

ParameterTypeDefaultDescription
partialobject—Fields merged into an object root.
keyunknown—Existing array index or Map key.
valueunknown—Replacement or shallow-merge value.
silentbooleanfalseSuppress dispatched events.
Returns
true when accepted; false for an invalid call, missing member, or validator rejection.
Events / side effects
change → update unless silent.
Runtime
Browser + Node.js
  • Object updates are shallow, not recursive.
  • Prototype-pollution keys are blocked from merge input.

push(valueOrValues, silent?) / push(key, value, silent?)

Append array values or insert Map entries.

ParameterTypeDefaultDescription
valueOrValuesunknown | unknown[]—Value or values appended to an array root.
keyunknown—Map key.
valueunknown—Map value.
Returns
true when accepted; false for invalid calls, object roots, or validator rejection.
Events / side effects
change → push unless silent.
Runtime
Browser + Node.js

delete(key, silent?)

Delete one object property, array index, or Map entry.

ParameterTypeDefaultDescription
keyPropertyKey | number | unknown—Member to remove.
silentbooleanfalseSuppress dispatched events.
Returns
true when removed; false when the member is missing/invalid or validation rejects the resulting state.
Events / side effects
change → delete unless silent.
Runtime
Browser + Node.js

clear(silent?)

Reset the root to an empty value of the same shape.

Returns
true.
Events / side effects
change → clear unless silent.
Runtime
Browser + Node.js
  • Validation is intentionally bypassed so cleanup cannot be blocked.

destroy()

Clear state silently and release Model-owned listeners.

Returns
The same Model instance after cleanup.
Runtime
Browser + Node.js

TypeScript

Model<T> describes the supported root state and narrows known Model event CustomEvent.detail values.

TypeScript does not validate runtime data; add the optional validator when an external trust boundary needs enforcement.

Security and trust boundaries

Prototype-pollution keys are blocked from direct object writes and safe merge paths.

Validate API, storage, decoded JSON, or user-controlled data before treating it as trusted application state.

Design boundaries

Model does not own rendering, routing, networking, persistence, or application-wide event orchestration.

Resolve asynchronous I/O first, then apply the result with a synchronous Model mutation.