Core Concepts

Leaf identifier watching

Understand what leaf identifiers are, how rs-x watches their values by default, and how to extend that observation with an IIndexWatchRule.

What it means

A leaf identifier is the identifier node in an expression that directly reads a value. It is the immediate property access — the first (and direct) read on whatever context it finds itself on. In a + b, both a and b are leaf identifiers: each is read directly from the model. In user.profile.name, name is the leaf identifier: it is the direct read on user.profile.

By default, rs-x watches the leaf identifier for direct value replacement. For user.profile.name, the watch is on the 'name' key of user.profile; only assigning user.profile.name = 'Bob' fires reevaluation. Changing a different property like user.profile.role does not. user.profile.name is a member expression; the segments user and user.profile are also watched for reference replacement. Replacing either re-attaches the watch, and if the resolved value of name has changed as a result, a change event is emitted.

When the identifier's value is an Array, Map, Set, or Date, rs-x automatically wraps it in a Proxy. No watch rule is needed: mutations like array.push() or map.set() fire the expression the same way a direct assignment does.

When you need to observe mutations inside the identifier's value, pass an IIndexWatchRule as the second argument: rsx('expr')(model, watchRule). The rule's test(index, target)method is called at the leaf level to decide which sub-properties of the identifier's value should be observed. Returning true for a sub-property key installs an observer for it; returning false leaves it unwatched.

Practical value

The default behaviour is correct and efficient for the vast majority of models. Consider using a rule when:

  • You want to react to mutations inside the identifier's value, not just reference replacement — use watchIndexRecursiveRule to widen observation.
  • You only want to react to specific sub-property changes inside the identifier's value — write a custom IIndexWatchRule whose test() returns true only for the properties you care about.

Key points

What fires reevaluation by default

rs-x registers a watch on the specific (identifierKey, context) pair. For user.profile.name, that is ('name', user.profile). A direct assignment to that key — user.profile.name = 'Bob' — fires the expression. An assignment like user.profile.role = 'PM' targets a different key and is not observed.
For member expressions, the segments above the leaf are also watched for reference replacement. Replacing user.profile or user re-attaches the watch to the new objects; if the resolved value of name has changed as a result, a change event is emitted. This keeps the expression consistent even when the model is restructured above the leaf.

Automatic Proxy for arrays and collections

When the identifier's value is an Array, Map, Set, or Date, rs-x automatically wraps it in a JavaScript Proxy. The proxy intercepts mutation methods (push, splice, pop, set, setter calls on Date) and treats them as change events, triggering reevaluation just as a direct assignment would.
No watch rule is required for this behaviour — it is determined by the value's type.
const model = { items: [1, 2, 3] };
const expression = rsx('items')(model);

// All of these fire reevaluation automatically:
model.items.push(4);
model.items[0] = 99;
model.items.splice(1, 1);

Widening with watchIndexRecursiveRule

By default, only direct assignment to the identifier fires reevaluation — mutations inside the value do not. If you want any internal mutation to fire, pass watchIndexRecursiveRule from @rs-x/state-manager.
This rule always returns true from test(), so rs-x installs observers for every sub-property of the identifier's value, recursively. For rsx('config.theme')(model, watchIndexRecursiveRule), a write to config.theme.color fires the expression even though the theme reference itself was not replaced.
import { watchIndexRecursiveRule } from '@rs-x/state-manager';

const expression = rsx('config.theme')(model, watchIndexRecursiveRule);
// config.theme.color = 'red' now fires reevaluation.

Selective widening with a custom IIndexWatchRule

When only specific sub-properties should trigger reevaluation, write a custom IIndexWatchRule whose test(index, target) returns true only for the keys you care about — observation is extended to those sub-properties only; all others remain unwatched.
Like watchIndexRecursiveRule, a custom rule adds observation on top of the reference-only default. The difference is that test() opts in selectively rather than for every sub-property.
const watchRule = {
  id: 'watch-name-only',
  context: { tracked: new Set(['name']) },
  test(index) {
    return this.context.tracked.has(String(index));
  },
  dispose() {},
};

// Expression returns user.profile (a plain object).
// Only changes to profile.name fire; profile.role changes are ignored.
const expression = rsx('user.profile')(model, watchRule);

Default behaviour example

The watch is specific to the leaf identifier key. Assigning user.profile.name or replacing an intermediate reference (user.profile) fires; changing an unrelated property like role does not.
import { InjectionContainer } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';

await InjectionContainer.load(RsXExpressionParserModule);

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

// 'name' is the leaf identifier — the direct read on user.profile.
// rs-x watches user.profile['name'] for direct assignment.
// user.profile.name is a member expression. The segments user and user.profile
// are also watched; replacing either re-attaches the watch and emits a change
// if name's value changes.
const expression = rsx<string>('user.profile.name')(model);

expression.changed.subscribe(() => {

Array — automatic Proxy example

When the identifier value is an Array, rs-x wraps it in a Proxy automatically. push and index writes fire without any watch rule.
import { InjectionContainer } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';

await InjectionContainer.load(RsXExpressionParserModule);

const model = {
  items: [1, 2, 3],
};

// 'items' is the leaf identifier. Its value is an Array, so rs-x
// automatically wraps it in a Proxy — no watch rule required.
const expression = rsx<number[]>('items')(model);

expression.changed.subscribe(() => {
  console.log('items:', expression.value);
});

// Fires — push mutates the array; the Proxy intercepts this.
setTimeout(() => {
  console.log('--- push ---');
  (model.items as number[]).push(4);
}, 1000);

Widening with watchIndexRecursiveRule example

watchIndexRecursiveRule widens observation: any mutation inside the identifier value (config.theme) fires the expression, not just reference replacement.
import { InjectionContainer } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';
import { watchIndexRecursiveRule } from '@rs-x/state-manager';

await InjectionContainer.load(RsXExpressionParserModule);

const model = {
  config: {
    theme: {
      color: 'blue',
      size: 'medium',
    },
  },
};

// Without a rule, only replacing config.theme itself fires the expression.
// watchIndexRecursiveRule widens observation: mutations anywhere inside the
// identifier's value (config.theme) also fire it.
const expression = rsx<object>('config.theme')(model, watchIndexRecursiveRule);

expression.changed.subscribe(() => {
  console.log('theme:', expression.value);

Selective widening with a custom IIndexWatchRule example

A custom IIndexWatchRule extends observation to specific sub-properties only. Changes to name fire; changes to role remain unwatched.
import { InjectionContainer } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';
import type { IIndexWatchRule } from '@rs-x/state-manager';

await InjectionContainer.load(RsXExpressionParserModule);

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

// Expression returns the whole profile object.
// Without a rule, only replacing user.profile itself fires.
// With a custom rule where test() returns true only for 'name',
// mutations to 'name' inside the value also fire, but 'role' does not.
const watchRule: IIndexWatchRule = {
  id: 'profile-name-only',
  context: { tracked: new Set(['name']) },