Core Concepts
Side effects
Use the sequence expression to run side-effect calls inline — each call tracks its own dependencies and re-runs only when they change.
Practical value
Key points
- Syntax: (sideEffect(a), sideEffect2(b), result) — any number of comma-separated segments; the last segment is the expression value.
- Each segment tracks its own dependencies. A side-effect call only re-runs when the fields it reads change — not when unrelated segments change.
- The expression value is the last segment. A side-effect-only expression can return undefined — the result is still tracked.
- Combine with && for conditional side effects: (count > limit && onOverflow(count), count) — the side effect runs only when the guard is truthy.
- Side effects defined this way are automatically disposed when expression.dispose() is called — no extra cleanup code needed.
- The sequence expression is a standard JavaScript comma operator. rs-x parses and tracks it the same way it tracks any other expression node.
How dependencies are isolated between segments
Each segment in a sequence is a fully independent sub-expression. trackPrice(price) only depends on price and trackQty(quantity) only depends on quantity. Changing price re-evaluates the first segment but not the second.
This isolation means side effects are not called more often than needed. A logging call watching one field will not re-run just because an unrelated field — even one in the same expression — changes.
Conditional side effects with &&
Because && short-circuits, (count > limit && onOverflow(count), count) only calls onOverflow when count > limit is truthy. The right-hand side of && is not evaluated — and therefore not re-run — when the guard is false.
The count field itself is still tracked as a dependency of the guard, so if count changes and the guard becomes true, onOverflow will be called on the next evaluation.
Side effects vs. subscribing to changed
Subscribing to expression.changed is the right approach when the reaction lives outside the model — for example, updating the DOM or sending an HTTP request from application code.
A sequence side-effect call is the right approach when the reaction belongs to the model itself — for example, recording to an audit log, updating a cache field, or triggering another model method. Keeping it in the expression string means it is co-located with the data it reads and is cleaned up with the expression.
Basic side effect with return value example
Call notify(status) as a side effect on every change. The expression value is status, and the call is re-run each time status changes.
import { emptyFunction, InjectionContainer, WaitForEvent } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';
await InjectionContainer.load(RsXExpressionParserModule);
const model = {
status: 'pending' as string,
auditLog: [] as string[],
notify(s: string) {
this.auditLog.push(`status changed to: ${s}`);
},
};
// notify(status) runs as a side effect; expression value is status
const expr = rsx<string>('(notify(status), status)')(model);
await new WaitForEvent(expr, 'changed').wait(emptyFunction);
console.log(expr.value); // 'pending'
console.log(model.auditLog); // ['status changed to: pending']
await new WaitForEvent(expr, 'changed', { ignoreInitialValue: true })
.wait(() => { model.status = 'active'; });Multiple side effects with independent tracking example
Two side-effect calls before the return value. Each call tracks its own dependency — trackPrice only re-runs when price changes, not when quantity changes.
import { emptyFunction, InjectionContainer, WaitForEvent } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';
await InjectionContainer.load(RsXExpressionParserModule);
const model = {
price: 100,
quantity: 3,
priceLog: [] as number[],
qtyLog: [] as number[],
trackPrice(p: number) { this.priceLog.push(p); },
trackQty(q: number) { this.qtyLog.push(q); },
};
// Two side effects run before the result is returned
const expr = rsx<number>('(trackPrice(price), trackQty(quantity), price * quantity)')(model);
await new WaitForEvent(expr, 'changed').wait(emptyFunction);
console.log(expr.value); // 300
console.log(model.priceLog); // [100]
console.log(model.qtyLog); // [3]
Conditional side effect example
Guard a side effect with &&. onOverflow is only called when count exceeds the threshold. The expression value is count regardless.
import { emptyFunction, InjectionContainer, WaitForEvent } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';
await InjectionContainer.load(RsXExpressionParserModule);
const model = {
count: 3,
alerts: [] as string[],
onOverflow(n: number) {
this.alerts.push(`overflow at ${n}`);
},
};
// onOverflow is only called when count > 5
const expr = rsx<number>('(count > 5 && onOverflow(count), count)')(model);
await new WaitForEvent(expr, 'changed').wait(emptyFunction);
console.log(expr.value); // 3
console.log(model.alerts); // [] — threshold not reached
await new WaitForEvent(expr, 'changed', { ignoreInitialValue: true })
.wait(() => { model.count = 8; });Cross-property tracking example
Side effects and the return value each depend on different fields. Changing userId only re-runs trackView. Changing profile.name only re-runs formatName and updates the expression value.
import { emptyFunction, InjectionContainer, WaitForEvent } from '@rs-x/core';
import { rsx, RsXExpressionParserModule } from '@rs-x/expression-parser';
await InjectionContainer.load(RsXExpressionParserModule);
const model = {
userId: 1,
profile: { name: 'Alice' },
viewHistory: [] as number[],
trackView(id: number) {
this.viewHistory.push(id);
},
formatName(name: string) {
return name.toUpperCase();
},
};
// trackView tracks userId; formatName tracks profile.name
// Each dependency is tracked independently
const expr = rsx<string>('(trackView(userId), formatName(profile.name))')(model);
await new WaitForEvent(expr, 'changed').wait(emptyFunction);