Beyond Angular Signals: Signals & Custom Render Strategies
Published: 2023-04-07 08:16:55


**TL;DR**: Angular Signals *might* make it easier to track all the expressions in a view *(Component or EmbeddedView)* and schedule custom render strategies in a very surgical way. Thus, enabling some exciting optimizations.

> 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**.

Let's explore how Angular Signals could allow us to overcome these performance bottlenecks with custom rendering strategies.

## 📜 From Tick to Sig

It has been a while now since the Angular team has been exploring *(way more than we can think)* alternative reactivity models and looking for something that lies between the extremes of __naive Zone.js__ *(i.e. Zone.js without `OnPush`)* and __Zoneless Angular__ combined with special pipes & directives like those provided by [RxAngular](https://github.com/rx-angular/rx-angular).

... then [Pawel Kozlowski](https://twitter.com/pkozlowski_os) [joined the Angular team as a full-time member](https://twitter.com/pkozlowski_os/status/1519746420898676739) and together with [Alex Rickabaugh](https://twitter.com/synalx) they merged into __Pawælex__.

In the meantime, while [Ryan Carniato](https://twitter.com/RyanCarniato) keeps insisting that he did not invent Signals, he undoubtedly made them popular in the JavaScript ecosystem *(Cf. [The Evolution of Signals in JavaScript](https://dev.to/this-is-learning/the-evolution-of-signals-in-javascript-8ob))* and eventually ended up influencing Angular.

That is how Pawælex & friends: [Andrew](https://twitter.com/AScottAngular), [Dylan](https://twitter.com/dylhunn) & [Jeremy](https://hachyderm.io/@jelbourn) made the [Angular Signals RFC](https://github.com/angular/angular/discussions/49685) happen.

## 😬 DOM updates are not that cheap

The fantastic thing about Signals is how frameworks and libraries like Angular, SolidJS, Preact or Qwik "magically" track changes and rerender whatever has to rerender without much boilerplate compared to more manual alternatives.

But wait! If they rerender whatever has to rerender, what happens if the performance bottleneck is the DOM update itself?

> Let's try updating 10.000 elements every 100ms...

```ts
@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...

![flamechart-default-render](//images.ctfassets.net/gowvxq3b4aid/7MsPX3c58XqiPBv6J2fAc0/3ffc5d4240f4a8cebd5b8d3faaa58e65/flamechart-default-render-cropped.png)

> ...and we can notice the frame rate dropping to somewhere around 20fps.

![frame-rate-default-render](//images.ctfassets.net/gowvxq3b4aid/3Y6BrW9hBoY7OGIdUptEGh/4ce5f0bf7a55f844c1456ed7af576e60/frame-rate-default-render.png)

## 🦧 Let's calm down a bit

The first solution which we might think of is simply updating the Signals only when we want to rerender, but that would require some boilerplate *(i.e. creating intermediate Signals, which are not computed Signals!)*, and this is how it would look like if we want to throttle a Signal:

```ts
@Component({
  ...
  template: `{{ throttledCount() }}`
})
class MyCmp {
  count = signal(0);
  throttledCount = throttleSignal(this.count, {duration: 1000});
  ...
}
```
Cf. [`throttleSignal()`](https://github.com/yjaaidi/experiments/blob/angular-signals-and-custom-render-strategies/src/app/signal-utils.ts).

but this has a couple of drawbacks:
- 🐞 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

What if the browser was sensitive? It would turn to us and say: "I'm tired of working so much and nobody cares about my efforts! From now on, I won't work if you don't look at me!"

We might probably agree!

In fact, why would we keep updating below the fold elements? Or more generally, why would we keep updating elements outside the viewport?

If we tried to implement this using an intermediate Signal, then the function would need a reference to the DOM element in order to know if it's in the viewport:

```ts
lazyCount = applyViewportStrategy(this.count, {element});
```

this would require more boilerplate and as the same Signal might be used in different places, then we would need an intermediate Signal for each usage.

While this could be solved using a structural directive, we would clutter the template instead:

```ts
template: `
  <span *lazyViewportSignal="count(); let countValue">{{ countValue }}</span>
  <span> x 2 = </span>
  <span *lazyViewportSignal="double(); let doubleValue">{{ doubleValue }}</span>
`
```

... which is far from ideal.

## 🤔 What about Eventual Consistency for DOM updates?

Another alternative is acting at the change detection level. If we can customize the rendering strategy, then we can easily postpone the rendering of the content below the fold.
> More precisely, we could stop updating the content outside the viewport until it's in the viewport.

While introducing such inconsistency between the state and the view might sound frightening. If applied wisely, this is nothing more than __Eventual Consistency__, meaning that we will eventually end up in a consistent state.

After all, we could state the following theorem *(obviously inspired by the [CAP Theorem](https://en.wikipedia.org/wiki/CAP_theorem))*
> The process of synchronizing the state and the view can't guarantee both consistency and availability.

Inspired by the work of my [RxAngular](https://github.com/rx-angular/rx-angular) friends, I thought that by combining something like [custom render strategies](https://www.rx-angular.io/docs/cdk/render-strategies) with the Signals tracking system, we could get the best of both worlds and achieve our goal in the most unobtrusive way.

This could look something like this:

```ts
@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

Obviously, my first move was to ask the Angular team *(more precisely, my dear friend Alex who is now part of Pawælex as mentioned before)* if there were any plans to provide an API to override how Signals trigger Change Detection.

**Alex said:** no.

**I heard:** not yet.

**Then I said:** thanks.

**And we simultaneously said:** bye.

That's when I put my coding apron and started trying some naive stuff.

My first try was nothing more than something like this:

```ts
/**
 * 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();
});
```
... but it didn't work.

> 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.

That's when I realized that we are lucky that this doesn't work because otherwise, this would mean that we would trigger change detection on our view whenever a Signal changes in any child or deeply nested child.

Something at the view level stopped the propagation of the Signals and acted as a boundary mechanism. I had to find what it was, and the best way was to jump into the source code.

*(Yeah! I know... I like to try random stuff first 😬)*

## 🔬 The Reactive Graph
In order for the Signals to track changes, Angular has to build a reactive graph. Each node in this graph extends the `ReactiveNode` abstract class.

There are currently four types of reactive nodes:
- **Writable Signals:** `signal()`
- **Computed Signals:** `computed()`
- **Watchers:** `effect()`
- **the Reactive Logical View Consumer:** the special one we need 😉
*(the introduction of Signal-based components will probably add more node types like component inputs)*

Each `ReactiveNode` [knows all of its consumers and producers](https://github.com/angular/angular/blob/e9dd7f00280b6d52fa2a21da793521db1a9d7a53/packages/core/src/signals/src/graph.ts#L91-L107) *(which are all `ReactiveNode`s)*. This is necessary in order to achieve the push/pull glitch-free implementation of Angular Signals.

![angular-signals-reactive-graph](//images.ctfassets.net/gowvxq3b4aid/5YOk0K7Z4XVIqwLKhoZf9r/d1cfd16cf36d198656b30031df94499d/angular-signals-reactive-graph.jpg)

This reactive graph is built using the [`setActiveConsumer()`](https://github.com/angular/angular/blob/e9dd7f00280b6d52fa2a21da793521db1a9d7a53/packages/core/src/signals/src/graph.ts#L28-L32) function which sets the currently active consumer in a global variable which is [read by the producer](https://github.com/angular/angular/blob/e9dd7f00280b6d52fa2a21da793521db1a9d7a53/packages/core/src/signals/src/graph.ts#L193-L220) when called in the same call stack.

Finally, whenever a reactive node might have changed, [it notifies its consumers](https://github.com/angular/angular/blob/e9dd7f00280b6d52fa2a21da793521db1a9d7a53/packages/core/src/signals/src/graph.ts#L183) by calling their `onConsumerDependencyMayHaveChanged()` method. 

## 🎯 The Reactive Logical View Consumer

While spelunking, and ruining my apron, I stumbled upon a surprising reactive node type that lives in IVy' renderer source code, the [`ReactiveLViewConsumer`](https://github.com/angular/angular/blob/e9dd7f00280b6d52fa2a21da793521db1a9d7a53/packages/core/src/render3/reactive_lview_consumer.ts).

> While writable Signals are the leaf nodes of the reactive graph, the Reactive Logical View Consumers are the root nodes.

Just like any other reactive node, this one implements the `onConsumerDependencyMayHaveChanged()` method, but not like any other reactive node, this one is bound to a view so it can control the change detection... and it does! by [marking the view as dirty](https://github.com/angular/angular/blob/e9dd7f00280b6d52fa2a21da793521db1a9d7a53/packages/core/src/render3/reactive_lview_consumer.ts#L32) when notified by a producer:

```ts
onConsumerDependencyMayHaveChanged() {
  ...
  markViewDirty(this._lView);
}
```

## 🐘 Sneaking *(like an elephant)* between Signals & Change Detection

Sadly, there doesn't seem to be any elegant way of overriding the current behavior of marking the view to check when Signals trigger a change notification...

...but, luckily, I have my coding apron on, so I am not afraid of getting dirty.

### 1. Create the embedded view

First, let's create a typical structural directive so we can create & control the embedded view.

```ts
@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**

For some reason, the `ReactiveLViewConsumer` is instantiated **after** the first change detection. My apron was already too dirty to dive any deeper, but my guess is that it is lazily initialized when Signals are used for performance's sake.

The workaround is to trigger change detection once before detaching the change detector:

```ts
viewRef.detectChanges();

viewRef.detach();
```

Aha! While writing this, I stumbled upon [this comment here](https://github.com/angular/angular/blob/2703fd626040c5e65401ebd776404a3b9e284724/packages/core/src/render3/interfaces/view.ts#L351)... so I was right! Finally once! Yeah!

### 3. Grab the `ReactiveLViewConsumer`

🙈

```ts
const reactiveViewConsumer = viewRef['_lView'][REACTIVE_TEMPLATE_CONSUMER /* 23 */];
```

### 4. Override the Signal notification handler like a monkey

Now that we have the `ReactiveLViewConsumer` instance, we can let the hacker in us override the `onConsumerDependencyMayHaveChanged()` method and trigger/skip/schedule change detection with the strategy of our choice, like a naive throttle:

```ts
let timeout;
reactiveViewConsumer.onConsumerDependencyMayHaveChanged = () => {
  if (timeout != null) {
    return;
  }

  timeout = setTimeout(() => {
    viewRef.detectChanges();
    timeout = null;
  }, 1000);
};
```

... or we can use [RxJS](https://rxjs.dev/) which is still one of the most convenient ways of handling timing-related strategies *(and it is already bundled anyway in most apps 😉)*

Cf. [ThrottleStrategyDirective](https://github.com/yjaaidi/experiments/blob/angular-signals-and-custom-render-strategies/src/app/throttle-strategy.directive.ts) & [ViewportStrategyDirective](https://github.com/yjaaidi/experiments/blob/angular-signals-and-custom-render-strategies/src/app/viewport-strategy.directive.ts)

## 🚀 and it works!
Let's try!

![viewport-strategy](//images.ctfassets.net/gowvxq3b4aid/5tYvJf2tceByKoqVBLJHCO/356e910fcfbbd8e32fd4d00f06d08c0f/viewport-strategy.gif)

This seems to be **at least 5 times faster**... *(even though, tracking the element appearance in the viewport is a relatively expensive task)*

![flamechart-viewport-strategy](//images.ctfassets.net/gowvxq3b4aid/1qPtr98ZnNoiWlgzzsnOoJ/905730297afbe3cf737d9584567365a6/flamechart-viewport-strategy.png)

and the frame rate is pretty decent:

![frame-rate-viewport-strategy](//images.ctfassets.net/gowvxq3b4aid/7kYUHJ2aBCgeETKXkYWHEZ/473e3cbe60492dcba7590dd624002f43/frame-rate-viewport-strategy.png)

... but note that:

> This might break in any future version *(major or minor)* of Angular. Maybe, you shouldn't do this at work.


Also, this only tracks the view handled by the directive. It won't detach and track child views or components. 

## 🔮 What's next?

### 🚦 RxAngular + Signals

The strategies implemented in our demo are willingly naive and they need better scheduling and coalescing to reduce the amount of reflows & repaints.
Instead of venturing into that, this could be combined with [RxAngular Render Strategies](https://www.rx-angular.io/docs/cdk/render-strategies)... wink, wink, wink! 😉 to my RxAngular friends.

### 🅰️ We might need more low-level Angular APIs

To achieve our goal, we had to hack our way into Angular internals which might change without notice in future versions.

If Angular could provide some additional APIs like:
```ts
interface ViewRef {
  /* This doesn't exist. */
  setCustomSignalChangeHandler(callback: () => void);
}
```
... or something less verbose 😅, we could combine this with `ViewRef.detach()` and easily sneak in between Signals and change detection.

### Signal-Based Components

As of today, Signal-based components are not implemented yet so there is no way to know if this would work, as implementation details will probably change.

# ⚛ Custom Render Strategies in some other Libraries & Frameworks

What about other libraries and frameworks?

I couldn't refrain from asking, so [I did](https://twitter.com/yjaaidi/status/1642193294900842497) and received interesting feedback from SolidJS's [Ryan Carniato](https://twitter.com/RyanCarniato) & Preact's [Jason Miller](https://twitter.com/_developit):

### SolidJS

[![solidjs-ryan-carniato-render-strategy](//images.ctfassets.net/gowvxq3b4aid/1u1pnRq07aElFkwoAfjPLa/fd5ab3b48d1d3d1dfdf0879c0ec12b43/solidjs-ryan-carniato-render-strategy-cropped.png)](https://twitter.com/RyanCarniato/status/1642956077976399872)

### Preact

[![preact-jason-miller-render-strategy](//images.ctfassets.net/gowvxq3b4aid/4AEdMShFiCni4ikJVwzdbo/e57db97e52a3d97eb24b58066e24edf2/preact-jason-miller-render-strategy-cropped.png)](https://twitter.com/_developit/status/1643134288555302912)

### React

In React, no matter if we are using Signals or not, we could implement a [Higher Order Component](highlight://1) that decides whether to really render or return a memoized value depending on its strategy.

```ts
const CounterWithViewportStrategy = withViewportStrategy(() => <div>{count}</div>);
                                                         
export function App() {
  ...
  return <>
    {items.map(() => <CounterWithViewportStrategy count={count}/>}
  </>
}
```

Cf. [React Custom Render Strategies Demo](https://stackblitz.com/edit/react-custom-render-strategies?file=App.tsx)

This could probably be more efficient with Signals if achieved by wrapping `React.createElement` [like Preact Signals integration does](https://github.com/preactjs/signals/blob/0a585660e141f3d92fb8789c234e69d5a1da8a86/packages/react/src/index.ts#L207) and implementing a custom strategy instead of the [default behavior](https://github.com/preactjs/signals/blob/0a585660e141f3d92fb8789c234e69d5a1da8a86/packages/react/src/index.ts#L136).
Or maybe, using a custom hook based on [`useSyncExternalStore()`](https://react.dev/reference/react/useSyncExternalStore).

### Vue.js

Using JSX, we could wrap the `render()` just like `withMemo()` does:

```ts
defineComponent({
  setup() {
    const count = ref(0);

    return viewportStrategy(({ rootEl }) => (
      <div ref={rootEl}>{ count }</div>
    ));
  },
})
```

Cf. [throttle example on Stackblitz](https://stackblitz.com/edit/vue-custom-render-strategy?file=src%2Fcomponents%2FCounter.jsx)

... but I'm still wondering how this could work in SFC without having to add a compiler node transform to convert something like `v-viewport-strategy` into a wrapper. 🤔

### Qwik

This one needs a bit more investigation 😅, and I am not sure if overriding the default render strategy is currently feasible. 
However, my first guess would be that this can be "qwikly" added to the framework.
For example, there could be an API allowing us to toggle a component's "DETACHED" flag which would skip scheduling component render in [`notifyRender()`](https://github.com/BuilderIO/qwik/blob/9c555682ef8e708c12c8d284aa668853888c981e/packages/qwik/src/core/render/dom/notify-render.ts#L58-L85).

## 👨🏻‍🏫 Closing Observations

### ☢️ Please, don't do this at work!
The presented solution is based on internal APIs that might change at any moment, including the next Angular minor or patch versions.

So why write about it? My goal here is to show some new capabilities that could be enabled thanks to Signals while improving the Developer eXperience at the same time. 

### Wanna try custom render strategies before switching to Signals?
Check out [RxAngular's template](https://www.rx-angular.io/docs/template)

### Conclusion
While custom render strategies can instantly improve performance in some specific situations, the final note is that you should prefer **keeping a low number of DOM elements** and **reducing the number of updates**.

> 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)*.

---
## 🔗 Links & Upcoming Workshops
👨🏻‍🏫 [Workshops](https://marmicode.eventbrite.com)

📰 [Subscribe to Newsletter](https://gmail.us3.list-manage.com/subscribe?u=915d6ba70c9c00912ba326214&id=71255f30c7)

💻 [Source Code Repository](https://github.com/yjaaidi/experiments/blob/angular-signals-and-custom-render-strategies/src/app/counter.component.ts) 

💬 [Discuss this on github](https://github.com/marmicode/marmicode/discussions/82)