
Beyond Angular Signals: Signals & Custom Render Strategies

While libraries and frameworks are getting better and better at tracking changes in a fine-grained way and propagating them to the DOM, we might notice that sometimes, the performance bottleneck resides in DOM updates .
๐ From Tick to Sig
OnPush
๐ฌ DOM updates are not that cheap
Let's try updating 10.000 elements every 100ms ...
@Component({
...
template: `
<div *ngFor="let _ of lines">{{ count() }}</div>
`,
})
export class CounterComponent implements OnInit {
count = signal(0);
lines = Array(10_000);
ngOnInit() {
setInterval(() => this.count.update(value => value + 1), 100);
}
}
Oups ! We're spending more than 90% of our time rendering...
...and we can notice the frame rate dropping to somewhere around 20fps.
๐ฆง Let's calm down a bit
@Component({
...
template: `{{ throttledCount() }}`
})
class MyCmp {
count = signal(0);
throttledCount = throttleSignal(this.count, {duration: 1000});
...
}
throttleSignal()
๐ using a single unthrottled Signal in the same view would defeat our efforts, โฑ๏ธ if intermediate Signals scheduled updates are not coalesced, we might introduce some random inconsistencies and break the whole glitch-free implementation of Signals.
๐บ Updating the viewport only
lazyCount = applyViewportStrategy(this.count, {element});
template: `
<span *lazyViewportSignal="count(); let countValue">{{ countValue }}</span>
<span> x 2 = </span>
<span *lazyViewportSignal="double(); let doubleValue">{{ doubleValue }}</span>
`
๐ค What about Eventual Consistency for DOM updates?
More precisely, we could stop updating the content outside the viewport until it's in the viewport.
The process of synchronizing the state and the view can't guarantee both consistency and availability.
@Component({
...
template: `
<div *viewportStrategy>
<span>{{ count() }}</span>
<span> x 2 = </span>
<span>{{ double() }} </span>
</div>
`,
})
export class CounterComponent implements OnInit {
count = Signal(0);
double = computed(() => count());
}
๐จ๐ปโ๐ณ Sneaking between Signals & Change Detection
/**
* This doesn't work as expected!
*/
const viewRef = vcr.createEmbeddedView(templateRef);
viewRef.detach();
effect(() => {
console.log('Yeay! we are in!'); // if called more than once
viewRef.detectChanges();
});
The naive idea behind this was that if effect()
can track Signal calls and if detectChanges()
has to synchronously call the Signals in the view, then the effect should run again each time a Signal changes.
๐ฌ The Reactive Graph
ReactiveNode
Writable Signals: signal()
Computed Signals: computed()
Watchers: effect()
the Reactive Logical View Consumer: the special one we need ๐
ReactiveNode
ReactiveNode
setActiveConsumer()
onConsumerDependencyMayHaveChanged()
๐ฏ The Reactive Logical View Consumer
ReactiveLViewConsumer
While writable Signals are the leaf nodes of the reactive graph, the Reactive Logical View Consumers are the root nodes.
onConsumerDependencyMayHaveChanged()
onConsumerDependencyMayHaveChanged() {
...
markViewDirty(this._lView);
}
๐ Sneaking (like an elephant) between Signals & Change Detection
1. Create the embedded view
@Directive({
standalone: true,
selector: '[viewportStrategy]',
})
class ViewportStrategyDirective {
private _templateRef = inject(TemplateRef);
private _vcr = inject(ViewContainerRef);
ngOnInit() {
const viewRef = this._vcr.createEmbeddedView(this._templateRef);
}
}
2. Trigger change detection once
ReactiveLViewConsumer
viewRef.detectChanges();
viewRef.detach();
3. Grab the ReactiveLViewConsumer
ReactiveLViewConsumer
const reactiveViewConsumer = viewRef['_lView'][REACTIVE_TEMPLATE_CONSUMER /* 23 */];
4. Override the Signal notification handler like a monkey
ReactiveLViewConsumer
onConsumerDependencyMayHaveChanged()
let timeout;
reactiveViewConsumer.onConsumerDependencyMayHaveChanged = () => {
if (timeout != null) {
return;
}
timeout = setTimeout(() => {
viewRef.detectChanges();
timeout = null;
}, 1000);
};
๐ and it works !
This might break in any future version (major or minor) of Angular. Maybe, you shouldn't do this at work.
๐ฎ What's next?
๐ฆ RxAngular + Signals
๐
ฐ๏ธ We might need more low-level Angular APIs
interface ViewRef {
/* This doesn't exist. */
setCustomSignalChangeHandler(callback: () => void);
}
ViewRef.detach()
Signal-Based Components
โ Custom Render Strategies in some other Libraries & Frameworks
SolidJS
Preact
React
const CounterWithViewportStrategy = withViewportStrategy(() => <div>{count}</div>);
export function App() {
...
return <>
{items.map(() => <CounterWithViewportStrategy count={count}/>}
</>
}
React.createElement
useSyncExternalStore()
Vue.js
render()
withMemo()
defineComponent({
setup() {
const count = ref(0);
return viewportStrategy(({ rootEl }) => (
<div ref={rootEl}>{ count }</div>
));
},
})
v-viewport-strategy
Qwik
notifyRender()
๐จ๐ปโ๐ซ Closing Observations
โข๏ธ Please, don't do this at work !
Wanna try custom render strategies before switching to Signals?
Conclusion
In other words, keep your apps simple (as much as you can) , organized, and your data flow optimized by design using fine-grained reactivity (whether you are using RxJS-based solutions, or Signals) .