Change Detection
Poll every frame, rebuild only on change. The Watch pattern provides efficient reactivity for view bindings that change rarely but trigger expensive work.
Related: Bindings (Learn) · Bindings in Depth · Hot Paths
The Problem
Re-evaluating every binding every frame is correct but not always efficient. Some bindings change rarely (dimensions, configuration, game phase) while others change every frame (entity positions). For infrequent changes that trigger expensive work - rebuilding a grid, tearing down and recreating child views - running that work every frame wastes resources.
Manual Previous-Value Tracking
The simplest approach is tracking the previous value yourself:
let prevScore = -1;
function refresh(): void {
const score = bindings.getScore();
if (score !== prevScore) {
prevScore = score;
label.text = String(score);
}
}This works well for one or two values. For views with many watched bindings, it becomes repetitive.
The watch() Helper
This project provides a watch() factory that wraps multiple getters and tracks changes with === comparison. On each poll() call, every getter is re-evaluated and the result reports which values changed:
Getters must return a Watchable type - string | number | boolean | null | undefined. Objects and arrays are excluded because === only checks reference identity, and does not detect mutations within objects or arrays. Watching an object or array directly is thus most often a bug as the likely intent does not match the actual behaviour. The Watchable restriction prevents such bugs. To watch a collection, derive a primitive (e.g. () => items.length).
const watcher = watch({
rows: bindings.getRows,
cols: bindings.getCols,
phase: bindings.getPhase,
});
function refresh(): void {
const w = watcher.poll();
if (w.rows.changed || w.cols.changed) {
rebuildGrid(w.rows.value, w.cols.value);
}
if (w.phase.changed) {
rebuildOverlay(w.phase.value);
}
}Each property on the poll result provides:
| Property | Type | Description |
|---|---|---|
changed | boolean | Whether the value differs from last poll |
value | T | The most recent value |
previous | T | The value from the prior poll |
All getters are polled unconditionally on every call - no short-circuit evaluation that might skip a poll and miss a change.
Note: watch() is a helper specific to this project, not an MVT architectural requirement. The underlying concept - polling for changes and acting only when values differ - can be implemented in whatever way suits your codebase.
When to Use Change Detection
| Situation | Approach |
|---|---|
| Value changes most frames (entity x/y) | Read binding directly - change detection adds overhead for no gain |
| Value changes rarely, reaction is cheap (text label) | Compare previous value - skip redundant updates |
| Value changes rarely, reaction is expensive (presentation rebuild) | Essential - avoid rebuilding 60 times per second |
The decision is straightforward: if the cost of detecting changes exceeds the cost of just doing the work, skip the detection.
Change Detection as Consumer-Defined Events
Another way to think of change detection is as consumer-defined events. Traditional event systems require the producer to decide what constitutes an event and emit it. Consumers must subscribe, unsubscribe, and hope the producer fires at the right granularity.
With change detection, the consumer defines what matters by choosing which bindings to watch and what to do when they change. The model does not need to know anyone is listening:
const watcher = watch({
phase: bindings.getGamePhase,
});
function refresh(): void {
const w = watcher.poll();
if (w.phase.changed) {
// This view decided phase transitions matter.
// The model didn't need an "onPhaseChange" event.
rebuildOverlay(w.phase.value);
}
}A second view can watch the same binding and react differently - or ignore it entirely. No event registration, no coupling to the producer's event API, and no risk of missing or double-handling an event.
Dynamic Child Lists
A common use case is rebuilding a list of child views when the underlying model collection changes. Watch the collection length to detect additions or removals:
const watcher = watch({
asteroidCount: () => game.asteroids.length,
bulletCount: () => game.bullets.length,
});
function refresh(): void {
const w = watcher.poll();
if (w.asteroidCount.changed) rebuildAsteroidViews();
if (w.bulletCount.changed) rebuildBulletViews();
}This avoids tearing down and recreating every child view on every frame. Only when the count actually changes does the rebuild run.
For the basics of bindings, see Bindings (Learn). For hot-path considerations, see Hot Paths.