Reactive change detection with ES6 Proxy scopes
Change detection is how a framework decides when to update the DOM to reflect new data. AngularJS used a polling mechanism called the digest cycle — every time something might have changed, AngularJS ran all registered watchers, compared old and new values, and repeated until nothing changed. AngularTS replaces this entirely with ES6 Proxy-based reactive observation: the framework knows exactly which property changed, which bindings depend on it, and schedules only those bindings to re-evaluate.
The digest cycle problem
In AngularJS, every $watch registered anywhere in the application was checked on every digest. A large application could have thousands of watchers running on every user interaction, mouse move, or HTTP response. This was O(n) in the number of watchers per cycle, and cycles could chain into one another.
AngularTS eliminates this entirely. There are no cycles. There is no polling.
How Proxy-based reactivity works
When AngularTS creates a scope via createScope(), it wraps the plain object in a Proxy whose handler is a Scope instance. The Scope class implements the set trap:
set(target, property, value, proxy): boolean {
target[property] = createScope(value, this); // recursively proxy nested objects
if (oldValue !== value) {
const listeners = this._watchers.get(property); // O(1) Map lookup
if (listeners) {
this._scheduleListener(listeners); // queue a microtask
}
}
return true;
}
The key points:
- O(1) lookup: Listeners are stored in a
Map<string, Listener[]>keyed by property name. Whencountchanges, only listeners registered under'count'are scheduled — not every watcher in the application. - Microtask scheduling:
_scheduleListenercallsqueueMicrotask(), which defers the flush until after the current synchronous call stack completes. Multiple changes to the same property in the same tick are coalesced. - Recursive proxying: When you assign an object —
$scope.user = { name: 'Alice' }— the new value is itself wrapped in aProxy. Nested property changes ($scope.user.name = 'Bob') are tracked just as well as top-level ones.
A concrete example
$scope.count = 0;
$scope.increment = function () {
$scope.count += 1;
// At this point the Proxy set trap fires:
// 1. Stores the new value
// 2. Looks up listeners for 'count' in the watchers Map
// 3. Schedules them via queueMicrotask
// 4. Returns — no $apply() needed
};
}]);
<button ng-click="increment()">+</button>
<span>{{ count }}</span> <!-- binding re-evaluates only when 'count' changes -->
</div>
When increment() fires, the Proxy intercepts the assignment to count, finds the binding registered for 'count', and schedules it. The DOM update happens on the next microtask — after increment() returns but before the browser renders the next frame.
Comparison with AngularJS
| AngularJS (1.x) | AngularTS | |
|---|---|---|
| Detection mechanism | Dirty-checking digest loop | ES6 Proxy set trap |
| Watcher lookup cost | O(n) — all watchers checked | O(1) — direct Map lookup by property name |
| Trigger | Manual digest entry required for async code | Automatic — Proxy intercepts every assignment |
| Update granularity | All watchers, all the time | Only bindings for the changed property |
| Nested objects | Shallow by default; $watchCollection needed for deep | Deep — nested objects are automatically proxied |
| Async code | Must manually enter change detection | No wrapping needed for standard async (Promise, fetch, etc.) |
When no $apply is needed
Because the Proxy fires synchronously on every assignment, any code that assigns to a scope property — whether inside a controller, a service callback, or a Promise handler — automatically triggers the right DOM update:
$scope.users = [];
$scope.loading = true;
// No $apply() needed — the Proxy intercepts the assignment
$http.get('/api/users').then(({ data }) => {
$scope.users = data; // triggers DOM update for {{ users }}
$scope.loading = false; // triggers DOM update for ng-if="loading"
});
}]);
Tip: If you are integrating a third-party library that updates data outside of AngularTS (such as a raw WebSocket callback or a non-Promise-based timer), and the library stores results in scope properties, those assignments still flow through the Proxy and trigger updates automatically. No
$applywrapper is needed.
Microtask batching
Multiple property changes within the same synchronous block are batched. The listener scheduler enqueues a queueMicrotask flush only once per tick:
_enqueueScheduledTask(task: ScheduledTask): void {
const scheduler = this._listenerScheduler;
scheduler._queue.push(task);
if (!scheduler._queued && !scheduler._flushing) {
scheduler._queued = true;
queueMicrotask(() => {
this._flushScheduledTasks(); // runs all queued listener notifications at once
});
}
}
If you set ten properties in one synchronous function, all ten listener notifications are flushed together in a single microtask. The DOM is updated once, not ten times.
Post-render layout work
Use $afterRender when a controller needs to read layout after AngularTS has applied the DOM work from the current flush:
class BoardGridController {
$afterRender() {
this.realignShips();
}
}
AngularTS coalesces $afterRender to one callback per controller instance per render flush. The hook runs after directive and binding DOM mutations have completed, after linked children from structural directives such as ng-repeat exist, after class/style/attribute bindings for that flush are applied, and after one browser animation frame gives layout a chance to settle. It does not wait for external resources such as fonts or images by default.
For explicit scheduling outside the controller lifecycle, import afterRender or queueAfterRender:
import { afterRender } from '@angular-wave/angular.ts';
afterRender(() => {
this.realignShips();
});
If font metrics are required, opt in per callback:
afterRender(() => {
this.realignLabels();
}, { fonts: true });
What objects are tracked
Not all objects are proxied. The isNonScope function explicitly excludes:
- Browser built-ins:
Window,Document,Element,Node,Event,Promise,Map,Set,WeakMap,Date,RegExp, typed arrays,Blob,File,URL, and others. - Objects that carry
$nonscope: trueon the instance or constructor.
This prevents the framework from wrapping DOM nodes or native collections in Proxies, which would be incorrect and slow.
// Prevents this class from being wrapped in a Proxy
static $nonscope = true;
readonly apiBase = 'https://api.example.com';
}
// Or per-instance:
$scope.rawData = Object.assign(new SomeClass(), { $nonscope: true });
When to use $watch
In the reactive proxy model, $watch is rarely necessary for keeping the DOM in sync — that happens automatically. Use $watch when you need to run side-effect code in response to a scope property changing:
$scope.$watch('selectedTab', function (newTab) {
externalTabWidget.activate(newTab);
});
// Trigger a service call when a search term changes
$scope.$watch('searchQuery', function (query) {
if (query && query.length > 2) {
searchService.find(query).then(results => {
$scope.results = results;
});
}
});
// React to a computed condition
$scope.$watch('items.length > 100', function (tooMany) {
$scope.showPagination = tooMany;
});
Note:
$watchon a constant expression — a bare string literal, number, or boolean — is evaluated exactly once and the returned deregistration function is a no-op. The runtime detects the_constantflag on the compiled expression and avoids registering a watcher at all.
Watcher count and $$watchersCount
You can inspect how many active watchers are registered in a scope subtree:
console.log($scope.$$watchersCount);
This is computed by walking the _watchers Map and counting entries whose _scopeId matches any scope in the subtree. It is useful for identifying scopes with unexpectedly high watcher counts during performance profiling.
Performance characteristics
Only affected bindings update
A change to user.name schedules only the bindings that depend on name. Thousands of unrelated bindings are never touched.
No re-entrancy problems
The scheduler tracks a _flushing flag. If a listener notification causes another property change, that change is enqueued and flushed in a follow-up microtask rather than re-entrantly.
Deep tracking at zero cost
Nested objects are proxied at assignment time, not at watch time. There is no `` or explicit deep-watch flag — all depths are tracked uniformly.
Scope destruction cleans up
When $destroy() is called, all watcher entries for that scope’s $id are removed from the shared _watchers Map in a single O(n) pass, with O(1) swap-pop removal for each matched entry.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.