API Reference

IIndexWatchRule

IIndexWatchRule is the gatekeeper for recursive observation. It decides which nested member/index updates are turned into reactive change events.

What It Controls

The rule is evaluated with test(index, target) whenever rs-x decides if a nested value should remain observed. Returning true keeps recursive observation active for that member path.

Without a watch rule, rs-x still tracks root assignments and collection membership mutations. But nested member/property changes under leaf values are only tracked when the rule allows them.

Current runtime behavior uses factory-managed rules keyed by (context, index) pairs to keep watch-rule identity stable and disposable.

Where To Pass The Rule

Use it either at expression binding time (leafIndexWatchRule) or directly in state manager (watchState(..., { indexWatchRule })).

rsx usage
import { rsx } from '@rs-x/expression-parser';
import type { IIndexWatchRule } from '@rs-x/state-manager';

const watchRule: IIndexWatchRule = {
  id: 'my-watch-rule',
  context: { allow: new Set(['a', 'b']) },
  test(index) {
    return this.context.allow.has(String(index));
  },
  dispose() {},
};

const model = { a: 1, b: 2, c: 3 };
const expression = rsx<number>('a + b')(model, watchRule);
state manager usage
import type { IStateManager } from '@rs-x/state-manager';
import type { IIndexWatchRule } from '@rs-x/state-manager';

const watchRule: IIndexWatchRule = {
  id: 'recursive-toggle',
  context: { recursive: true },
  test(_index, _target) {
    return this.context.recursive;
  },
  dispose() {},
};

stateManager.watchState(model, 'user', { indexWatchRule: watchRule });

Current Factory Implementation

IndexWatchRuleFactory creates lightweight rules that match exactly one context/index pair. This is the default watch-rule implementation used by state-manager.

import { IndexWatchRuleFactory } from '@rs-x/state-manager';

const model = { user: { profile: { name: 'Ada' } } };
const factory = new IndexWatchRuleFactory();

// The runtime creates a rule for one (context, index) pair.
// Rule semantics: test(nextIndex, nextTarget) is true only when
// nextIndex === index && nextTarget === context.
const rule = factory.create(model, 'user');

// Use it in watchState/rsx APIs, then dispose when done.
stateManager.watchState(model, 'user', { indexWatchRule: rule });
rule.dispose();

Parameters

idunknown

Stable identifier for rule identity/reference tracking.

contextunknown

User-defined data bag used by test(...) to hold rule config.

indexunknown

Current member/index candidate under evaluation.

targetunknown

Object/collection that owns the current index/member.

dispose() => void

Releases rule resources when no longer needed.

Return Type

test(...) returns boolean.

Return true to include a member in recursive observation; return false to skip it.

Index / Target Semantics By Type

The meaning of index depends on runtime type:

ArrayNumeric slot indexThe array instanceArray.isArray(target) && index === 0
DateDate part key ('year', 'month', 'time', ...)The Date instancetarget === model.date && index === 'hours'
MapMap keyThe map instancetarget instanceof Map && index === 'admin'
Plain objectProperty key (string/symbol)The object that owns the propertytarget === model.user && index === 'profile'
SetMember value itselfThe set instancetarget instanceof Set && trackedMembers.has(index)

Practical Pattern

The usual rule shape is:

  • Allow the leaf container itself (for example model.items) so recursive observation can be installed.
  • Allow specific collection members/keys or nested object properties.
  • Keep rule logic deterministic and side-effect free.
Watch everything under the leaf

Use the pre-built watchIndexRecursiveRule to enable recursive observation without writing a custom rule.

import { rsx } from '@rs-x/expression-parser';
import { watchIndexRecursiveRule } from '@rs-x/state-manager';

const model = { a: { b: { c: 1 } } };
const expression = rsx('a.b')(model, watchIndexRecursiveRule);

Plain Object Example

Tracks only user.profile.name while ignoring user.profile.role.

const model = {
  user: {
    profile: { name: 'Ada', role: 'engineer' },
  },
};

const watchRule = {
  context: { trackedLeafProperties: new Set(['name']) },
  test(index, target) {
    if (target === model && index === 'user') {
      return true;
    }

    if (target === model.user && index === 'profile') {
      return true;
    }

    if (target === model.user.profile) {
      return this.context.trackedLeafProperties.has(String(index));
    }

    return false;

Date Example

Tracks schedule.start hours/minutes updates and ignores seconds.

const model = {
  schedule: {
    start: new Date(2026, 0, 1, 9, 30, 0),
  },
};

const watchRule = {
  context: { trackedDateParts: new Set(['hours', 'minutes']) },
  test(index, target) {
    if (target === model && index === 'schedule') {
      return true;
    }

    if (target === model.schedule && index === 'start') {
      return true;
    }

    if (target === model.schedule.start) {
      return this.context.trackedDateParts.has(String(index));
    }

    return false;

Array Example

Tracks array slot mutations and qty changes on each item, but not note.

const model = {
  items: [
    { label: 'A', qty: 1, note: 'keep' },
    { label: 'B', qty: 2, note: 'keep' },
  ],
};

const watchRule = {
  context: { trackedItemProperty: 'qty' },
  test(index, target) {
    if (target === model && index === 'items') {
      return true;
    }

    if (Array.isArray(target)) {
      return true; // watch each array slot
    }

    return String(index) === this.context.trackedItemProperty;
  },
};

Map Example

Tracks only the admin key branch and enabled property changes.

const emptyFunction = () => {};

const admin = { enabled: true, rank: 1 };
const guest = { enabled: false, rank: 2 };

const model = {
  roles: new Map([
    ['admin', admin],
    ['guest', guest],
  ]),
};

const watchRule = {
  context: { trackedKeys: new Set(['admin']) },
  test(index, target) {
    if (target === model && index === 'roles') {
      return true;
    }

    if (target instanceof Map) {
      return this.context.trackedKeys.has(String(index));
    }

Set Example

Tracks a set member path by default (non-recursive): nested done is ignored, membership changes are tracked.

const emptyFunction = () => {};

const taskA = { id: 'A', done: false, note: 'leaf object' };
const taskB = { id: 'B', done: false, note: 'other object' };

const model = {
  trackedTask: taskA,
  tasks: new Set([taskA, taskB]),
};

// Default: non-recursive leaf watching
const trackedTaskExpression = rsx('tasks[trackedTask]')(model);
  await new WaitForEvent(trackedTaskExpression, 'changed').wait(emptyFunction);

  const nestedChange = await new WaitForEvent(trackedTaskExpression, 'changed', {
    ignoreInitialValue: true,
    timeout: 100,
  }).wait(() => {
    taskA.done = true;
  });
  console.log('nested done change emitted:', nestedChange !== null); // false