Skip to main content

RxLet

Motivationโ€‹

In Angular there is one way to handle asynchronous values or streams in the template, the async pipe. Even though the async pipe evaluates such values in the template, it is insufficient in many ways. To name a few:

  • it will only update the template when NgZone is also aware of the value change
  • it leads to over rendering because it can only run global change detection
  • it leads to too many subscriptions in the template
  • it is cumbersome to work with values in the template
@if (number$ | async; as n) {
<app-number [number]="n" />
<app-number-special [number]="n" />
}

The problem is that *ngIf interferes with rendering and in case of falsy values (0, '', false, null, undefined) the component would be hidden. This issue is a big problem and leads to many production bugs as its edge cases are often overlooked.

Downsides of the "ngIf-hack"

  • Performance issues from the subscriptions in pipe's
  • Over rendering
  • Boilerplate in the template
  • Typings are hard to handle due to null and undefined
  • Inefficient change detection (Evaluation of the whole template)
  • New but same values (1 => 1) still trigger change detection
  • Edge cases cause unexpected bugs
  • No contextual information given

Conclusion - Structural directives

In contrast to global change detection, structural directives allow fine-grained control of change detection on a per directive basis. The RxLet comes with its own way to handle change detection in templates in a very efficient way. However, the change detection behavior is configurable on a per directive or global basis. This makes it possible to implement your own strategies, and also provides a migration path from large existing apps running with Angular's default change detection.

This package helps to reduce code used to create composable action streams. It mostly is used in combination with state management libs to handle user interaction and backend communication.

<ng-container *rxLet="number$; let n"> ... </ng-container>

Basic Usageโ€‹

tip

By default *rxLet is optimized for performance out of the box. This includes:

  • The default render strategy is normal.
    • This ensures non-blocking rendering but can cause other side-effects. See strategy configuration if you want to change it.
  • Creates templates lazy and manages multiple template instances

Binding a value in the templateโ€‹

The *rxLet directive makes it easy to work with reactive data streams in the template.

This can be achieved by using Angulars native 'let' syntax *rxLet="number$; let n"

src/counter.component.html
<ng-container *rxLet="number; let n">
<app-number [number]="n"></app-number>
<app-number-special [number]="n"></app-number-special>
</ng-container>
src/counter.component.ts
import { RxLet } from '@rx-angular/template/let';
import { signal } from '@angular/core';

@Component({
imports: [RxLet],
templateUrl: './counter.component.html',
standalone: true,
})
export class CounterComponent {
number = signal(0);
}

Using the reactive contextโ€‹

Contextual-State--template-vs-variable

A nice feature of the *rxLet directive is, it provides 2 ways to access the reactive context state in the template:

  • context variables
  • context templates
note

The full reactive context (suspense, error, complete) can only be derived from Observable sources.

If you provide a Signal, only suspense & error can be derived.

Context Variablesโ€‹

The following context variables are available for each template:

  • $implicit: T // the default variable accessed by let val
  • error: undefined | Error
  • complete: undefined |boolean
  • suspense: undefined |true

You can use the as like this:

<ng-container *rxLet="number$; let n; let s = suspense; let e = error, let c = complete">
{{ s && 'No value arrived so far' }}

<app-number [number]="n"></app-number>

There is an error: {{ e ? e.message : 'No Error' }} Observable is completed: {{c ? 'Yes' : 'No'}}
</ng-container>

Context Templatesโ€‹

You can also use template anchors to display the contextual state in the template:

<ng-container
*rxLet="
number$; let n;
error: error;
complete: complete;
suspense: suspense;
"
>
<app-number [number]="n"></app-number>
</ng-container>

<ng-template #suspense>SUSPENSE</ng-template>
<ng-template #error>ERROR</ng-template>
<ng-template #complete>COMPLETE</ng-template>

This helps in some cases to organize the template and introduces a way to make it dynamic or even lazy.

Context Triggerโ€‹

context-templates

You can also use asynchronous code like a Promise or an Observable to switch between templates. This is perfect for e.g. a searchable list with loading spinner.

If applied the trigger will apply the new context state, and the directive will update the local variables, as well as switch to the template if one is registered.

Showing the next templateโ€‹

We can use the nextTrg input to switch back from any template to display the actual value. e.g. from the complete template back to the value display

@Component({
selector: 'app-root',
template: `
<button (click)="nextTrigger$.next()">show value</button>
<ng-container *rxLet="num$; let n; complete: complete; nextTrg: nextTrigger$">
{{ n }}
</ng-container>
<ng-template #complete>โœ”</ng-template>
`,
})
export class AppComponent {
nextTrigger$ = new Subject();
num$ = timer(2000);
}

This helps in some cases to organize the template and introduces a way to make it dynamic or even lazy.

Showing the error templateโ€‹

We can use the errorTrg input to switch back from any template to display the actual value. e.g. from the complete template back to the value display

@Component({
selector: 'app-root',
template: `
<ng-container *rxLet="num$; let n; error: error; errorTrg: errorTrigger$">
{{ n }}
</ng-container>
<ng-template #error>โŒ</ng-template>
`,
})
export class AppComponent {
num$ = this.state.num$;
errorTrigger$ = this.state.error$;

constructor(private state: globalState) {}
}

Showing the complete templateโ€‹

We can use the completeTrg input to switch back from any template to display the actual value. e.g. from the complete template back to the value display

@Component({
selector: 'app-root',
template: `
<ng-container *rxLet="num$; let n; complete: complete; completeTrg: completeTrigger$">
{{ n }}
</ng-container>
<ng-template #complete>โœ”</ng-template>
`,
})
export class AppComponent {
num$ = this.state.num$;
completeTrigger$ = this.state.success$;

constructor(private state: globalState) {}
}

Showing the suspense templateโ€‹

We can use the suspenseTrg input to switch back from any template to display the actual value. e.g. from the complete template back to the value display

@Component({
selector: 'app-root',
template: `
<input (input)="search($event.target.value)" />
<ng-container *rxLet="num$; let n; let n; suspense: suspense; suspenseTrg: suspenseTrigger$">
{{ n }}
</ng-container>
<ng-template #suspense>loading...</ng-template>
`,
})
export class AppComponent {
num$ = this.state.num$;
suspenseTrigger$ = new Subject();

constructor(private state: globalState) {}

search(str: string) {
this.state.search(str);
this.suspenseTrigger$.next();
}
}

Using the contextTrgโ€‹

We can use the contextTrg input to set any context. It combines the functionality of suspenseTrg, completeTrg and errorTrg in a convenient way.

@Component({
selector: 'app-root',
template: `
<input (input)="search($event.target.value)" />
<ng-container *rxLet="num$; let n; suspense: suspense; contextTrg: contextTrg$">
{{ n }}
</ng-container>
<ng-template #suspense>loading...</ng-template>
`,
})
export class AppComponent {
num$ = this.state.num$;
contextTrg$ = new Subject();

constructor(private state: globalState) {}

search(str: string) {
this.state.search(str);
this.contextTrg$.next(RxNotificationKind.Suspense);
}
}

Conceptsโ€‹

Featuresโ€‹

DXโ€‹

  • context variables (error, complete, suspense)
  • context templates (error, complete, suspense)
  • context trigger
  • reduces boilerplate (multiple async pipe's)
  • a unified/structured way of handling null and undefined
  • works also with static variables *rxLet="42; let n"

Performance Featuresโ€‹

  • value binding is always present. ('*ngIf hack' bugs and edge cases)
  • lazy template creation (done by render strategies)
  • triggers change-detection on EmbeddedView level
  • distinct same values in a row (over-rendering)
  • concurrent mode (read more about this here)

Inputsโ€‹

Value

InputTypedescription
rxLetObservable<T>The Observable or value to be bound to the context of a template.

Contextual state

InputTypedescription
errorTemplateRef<RxLetViewContext>defines the template for the error state
completeTemplateRef<RxLetViewContext>defines the template for the complete state
suspenseTemplateRef<RxLetViewContext>defines the template for the suspense state
nextTriggerObservable<unknown>trigger to show next template
errorTriggerObservable<unknown>trigger to show error template
completeTriggerObservable<unknown>trigger to show complete template
suspenseTriggerObservable<unknown>trigger to show suspense template
contextTriggerObservable<RxNotificationKind>trigger to show any templates, based on the given RxNotificationKind

Rendering

InputTypedescription
patchZonebooleandefault: true if set to false, the RxLet will operate out of NgZone. See NgZone optimizations
parent (deprecated)booleandefault: true if set to false, the RxLet won't inform its host component about changes being made to the template. More performant, @ViewChild and @ContentChild queries won't work. Handling view and content queries
strategyObservable<RxStrategyNames \ string> \ RxStrategyNames \ string>default: normal configure the RxStrategyRenderStrategy used to detect changes.
renderCallbackSubject<U>giving the developer the exact timing when the RxLet created, updated, removed its template. Useful for situations where you need to know when rendering is done.

Advanced Usageโ€‹

Use render strategies (strategy)โ€‹

You can change the used RenderStrategy by using the strategy input of the *rxFor. It accepts an Observable<RxStrategyNames> or RxStrategyNames.

The default value for strategy is normal.

<ng-container *rxLet="item$; let item; strategy: strategy"> {{ item }} </ng-container>

<ng-container *rxFor="item$; let item; strategy: strategy$"> {{ item }} </ng-container>
@Component({
/**/
})
export class AppComponent {
strategy = 'low';
strategy$ = of('immediate');
}

Learn more about the general concept of RenderStrategies especially the section usage-in-the-template if you need more clarity.

Local strategies and view/content queries (parent)โ€‹

danger

Deprecation warning

The parent flag being true is not needed anymore with the new signal based view queries.

The flag itself is deprecated now and will be removed in future versions.

However, for the time being: if you are already using the signal queries, you definitely want to set the parent flag to be false. We highly recommend doing so, as it reduces the amount of change detection cycles significantly, thus improving the runtime performance of your apps.

You can do so by providing a custom RxRenderStrategiesConfig, see the following example:

// import
import { RxRenderStrategiesConfig, RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies';

// create configuration with parent flag to be false
const rxaConfig: RxRenderStrategiesConfig<string> = {
parent: false,
};

// provide it, in best case on root level
{
providers: [
{
provide: RX_RENDER_STRATEGIES_CONFIG,
useValue: rxaConfig,
},
];
}

Structural directives maintain EmbeddedViews within a components' template. Depending on the bound value as well as the configured RxRenderStrategy, updates processed by the @rx-angular/template directives can be asynchronous.

Whenever a template gets inserted into or removed from its parent component, the directive has to inform the parent in order to update any view or content queries (@ViewChild, @ViewChildren, @ContentChild, @ContentChildren).

This is required if your components state is dependent on its view or content children:

  • @ViewChild
  • @ViewChildren
  • @ContentChild
  • @ContentChildren

The following example will not work with a local strategy because @ViewChild, @ViewChildren, @ContentChild, @ContentChildren will not update.

To get it running with strategies like local or concurrent strategies we need to set parent to true. This is given by default. Set the value to false and it will stop working.

@Component({
selector: 'app-list-component',
template: ` <div *rxLet="state$; let state; parent: false"></div> `,
})
export class AppListComponent {}

Use a renderCallback to run post render processes (renderCallback)โ€‹

A notification channel of *rxLet that the fires when rendering is done.

This enables developers to perform actions based on rendering timings e.g. checking the DOM for the final height or send the LCP time to a tracking server.

It is also possible to use the renderCallback in order to determine if a view should be visible or not. This way developers can hide a display as long as it has not finished rendering and e.g show a loading spinner.

The result of the renderCallback will contain the currently rendered value of in DOM.

 @Component({
selector: 'app-root',
template: `
<ng-container *rxLet="num$; let n; renderCallback: valueRendered;">
{{ n }}
</ng-container>
`
})
export class AppComponent {
num$: Observable<number> = of();

// fires when rxLet finished rendering changes
valueRendered = new Subject<Item[]>();

...

init() {
// initializes the process
this.rxEffects.register(this.valueRendered, () => saveLCPTime());
}

}

Working with event listeners (patchZone)โ€‹

Event listeners normally trigger zone. Especially high frequently events cause performance issues. By using we can run all event listener inside rxLet outside zone.

For more details read about NgZone optimizations

@Component({
selector: 'app-root',
template: ` <div *rxLet="bgColor$; let bgColor; patchZone: false" (mousemove)="calcBgColor($event)" [style.background]="bgColor"></div> `,
})
export class AppComponent {
// As the part of the template where this function is used as event listener callback
// has `patchZone` false the all event listeners run outside zone.
calcBgColor(moveEvent: MouseEvent) {
// do something with the background in combination with the mouse position
}
}

Testingโ€‹

For testing we suggest to switch the CD strategy to native. This helps to exclude all side effects from special render strategies.

Basic Setupโ€‹

import { ChangeDetectorRef, Component, TemplateRef, ViewContainerRef } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { RX_RENDER_STRATEGIES_CONFIG } from '@rx-angular/cdk/render-strategies';
import { RxLet } from '@rx-angular/template/let';

@Component({
template: `
<ng-container *rxLet="value$; let value">
{{ value }}
</ng-container>
`,
})
class TestComponent {
value$: Observable<number> = of(42);
}

const setupTestComponent = (): void => {
TestBed.configureTestingModule({
declarations: [RxLet, TestComponent],
providers: [
{
// don't forget to configure the primary strategy to 'native'
provide: RX_RENDER_STRATEGIES_CONFIG,
useValue: {
primaryStrategy: 'native',
},
},
],
});

fixtureComponent = TestBed.createComponent(TestComponent);
component = fixtureComponent.componentInstance;
componentNativeElement = component.nativeElement;
};

Set default strategyโ€‹

do not forget to set the primary strategy to native in test environments

In test environments it is recommended to configure rx-angular to use the native strategy, as it will run change detection synchronously. Using the concurrent strategies is possible, but requires more effort when writing the tests, as updates will be processed asynchronously.

TestBed.configureTestingModule({
declarations: [RxLet, TestComponent],
providers: [
{
// don't forget to configure the primary strategy to 'native'
provide: RX_RENDER_STRATEGIES_CONFIG,
useValue: {
primaryStrategy: 'native',
},
},
],
});

Here is an example using the concurrent strategies in a test environment: rxLet strategy spec

Instantiationโ€‹

//...
class TestComponent {
value$: Observable<number> = of(42);
}

describe('RxLet', () => {
beforeEach(setupLetDirectiveTestComponent);

it('should be instantiable', () => {
expect(fixtureComponent).toBeDefined();
expect(testComponent).toBeDefined();
expect(componentNativeElement).toBeDefined();
expect(componentNativeElement.innerHTML).toBe('42');
});
});

Resourcesโ€‹

Example applications: A demo application is available on GitHub.