Key Takeaways
- The
LetDirectiveprovides a way to subscribe to an Observable and render values emitted by the source. - Beyond what the
AsyncPipeoffers, this directive can render distinct views based on the source Observable's "next", "error", "complete", and even "suspense" notifications. - Relying on a combination of
asyncpipe and*ngIfmight not cover every scenario. With theLetDirective, you take back control over which values get rendered in the view container. - To build a structural
LetDirective, you need a solid grasp of the relationship betweenViewContainerRefandTemplateRef, as well as how to detach and insert views. - When you create a view, you can also attach a view context to it, which allows you to bind data for use inside that specific view.
- The
NextObserverobject works as an argument to RxJS'stap()operator. This can be handy for showing different views in response to various Observable notifications. - @rx-angular/template is a package that offers the
LetDirective(plus other reactive tools) along with custom rendering strategies for better performance in large Angular apps, whether you use Zone.js or go fully zone-less.
Getting Started
This is the follow-up to our first piece where we explored the limitations of the AsyncPipe and started building our own structural LetDirective. You can find the first part here.
In the previous installment, we:
- Identified potential pitfalls when using the
AsyncPipeon its own. - Tackled a common scenario involving the
asyncpipe with*ngIfand noted areas for improvement. - Outlined the full set of requirements for our
LetDirectiveto address all the discussed issues. - Laid the groundwork for this part by typing our view context, defining the
LetDirectiveclass, and setting up the first input binding.
Now, let's focus on the core task: swapping views based on the source Observable's notifications.
Continuing the Implementation
Adding input bindings for the remaining templates
The next step is to wire up the input bindings for the "error" and "complete" notifications:
@Input()
set rxLetComplete(templateRef: TemplateRef<LetViewContext<T>>) {
// ...
}
@Input()
set rxLetError(templateRef: TemplateRef<LetViewContext<T>>) {
// ...
}
What should the logic inside these setters do? Our LetDirective needs to capture the user-provided template and store it for later use.
Storing templates from input bindings
We'll use a record to hold all our templates:
type TemplateRecord<T> = {
complete: TemplateRef<LetViewContext<T>> | undefined,
error: TemplateRef<LetViewContext<T>> | undefined,
next: TemplateRef<LetViewContext<T>> | undefined,
};
A TemplateRef represents the <ng-template></ng-template> passed to our directive. This value can either be an actual TemplateRef instance or undefined if the user didn't supply one. We'll add logic shortly to handle the missing template case.
Our "cache" should be added to the LetDirective with all its fields initialized to undefined:
private readonly templateCache = {} as TemplateRecord<T>;
Here, the as keyword from TypeScript lets us create an object with undefined fields in a concise way without losing type safety.
Next, we implement the caching logic for each template. For the "next" template, we can pull the TemplateRef directly through dependency injection:
constructor(private readonly nextTemplate: TemplateRef<LetViewContext<T>>) {
this.templateCache.next = this.nextTemplate;
}
For the "error" and "complete" templates, the caching is handled inside their respective input setters:
@Input()
set rxLetComplete(templateRef: TemplateRef<LetViewContext<T>>) {
this.templateCache.complete = templateRef;
}
@Input()
set rxLetError(templateRef: TemplateRef<LetViewContext<T>>) {
this.templateCache.error = templateRef;
}
Implementing view manipulation logic
To manage views within a container, you need to understand how ViewContainerRef functions. For our purposes, it represents the container where views are attached or detached — in this case, the element using the *rxLet directive. Angular's ViewContainerRef is an injectable that offers methods for injecting, clearing, moving, and detaching views.
For a deeper dive into DOM manipulation with
ViewContainerRef, check out these excellent articles by Max Koretskyi:
We can obtain the ViewContainerRef for the container hosting our *rxLet directive via Angular's dependency injection:
constructor(
private readonly nextTemplate: TemplateRef<LetViewContext<T>>,
private readonly viewContainerRef: ViewContainerRef
) {
this.templateCache.next = this.nextTemplate;
}
Let's create a reusable method to display views from the provided templates:
private displayView(name: keyof TemplateRecord<T>) {
if (this.templateCache[name]) { // (1)
this.viewContainerRef.detach(); // (2)
this.viewContainerRef.createEmbeddedView(this.templateCache[name], this.viewContext); // (3)
}
}
What's happening in this code?
-
We first check if the template is cached. If it isn't, we do nothing — after all, we can't render a template that doesn't exist.
-
We detach the currently displayed view.
The
ViewContainerRef#detachmethod takes an optional index for the view to detach; without an argument, it removes the last one. We always aim to have exactly one view in theViewContainerRef, so when switching views, we detach the previous one. -
We create and insert an embedded view from the
TemplateRefusingViewContainerRef#createEmbeddedView, passing along the view context.
To avoid repeating the "detach -> create -> insert" sequence for every call to displayView, we should track which view is currently active. We can do this by storing the name of the active view:
private activeView: keyof TemplateRecord<T>;
private displayView(name: keyof TemplateRecord<T>) {
if (this.activeView !== name && this.templateCache[name]) {
this.viewContainerRef.detach();
this.viewContainerRef.createEmbeddedView(this.templateCache[name], this.viewContext);
this.activeView = name;
}
}
Updating the view context
Just as we created a convenient LetDirective#displayView method, we'll add a method for updating the viewContext:
private updateViewContext(viewContextSlice: Partial<LetViewContext<T>>) {
Object.entries(viewContextSlice).forEach(([key, value]) => {
this.viewContext[key] = value;
});
}
This method takes a subset of fields from LetViewContext and updates those specific properties on the current viewContext.
You might wonder: why mutate instead of using immutability? When we call
ViewContainerRef#createEmbeddedView, the view context is attached to the new view. If we replaced theviewContextobject entirely, the link between the view and its context would break, and the UI wouldn't react to changes.
Switching views based on Observable notifications
We now have all the building blocks to update our view container:
displayViewfor detaching the old view and creating/inserting a new one from a cached template,updateViewContextto modify the view context linked to the active view.
The next step is to *react* to each value from the source Observable and invoke these two methods appropriately. A straightforward way is to use the tap operator on our Observable and pass it a NextObserver. This observer represents a consumer that handles at least the "next" notification through callbacks. Inside these callbacks, we implement our view management:
@Input()
set rxLet(sourceObservable: Observable<T>) {
this.subscription.unsubscribe();
this.sourceObservable = sourceObservable.pipe(
distinctUntilChanged(),
tap(this.updateObserver) // (11)
);
this.subscription = new Subscription().add(this.sourceObservable.subscribe());
}
// ...
private readonly updateObserver: NextObserver<T> = { // (1)
next: (value: T) => { // (2)
this.displayView('next'); // (3)
this.updateViewContext({ // (4)
$implicit: value,
rxLet: value
});
},
complete: () => { // (5)
if (this.templateCache.complete) { // (6)
this.displayView('complete');
} else {
this.displayView('next');
}
this.updateViewContext({ // (7)
$complete: true
});
},
error: (err: Error) => { // (8)
if (this.templateCache.error) { // (9)
this.displayView('error');
} else {
this.displayView('next');
}
this.updateViewContext({ // (10)
$error: err
});
}
};
This is quite dense, so let me walk you through it step-by-step.
- Define
updateObserveras a read-only field of theLetDirectiveclass. - Set up the "next" observer.
- Render the "next" template (this is always available; "error" and "complete" templates are optional).
- Update the view context fields that store the latest value from the source Observable.
- Add the "complete" observer.
- If the "complete" template is provided, show it; otherwise, fall back to the "next" template.
- Update the context field for the "complete" notification,
$complete. - Add the "error" observer.
- If the "error" template exists, display it; otherwise, use the "next" template.
- Update the context field for the error,
$error, with the error object. - Pipe the
updateObserverinto the source Observable.
With this significant step, we've covered the remaining four requirements in one shot:
- Emit values from the Observable.
- Stop emitting when the Observable completes.
- Stop emitting and log an error if the Observable errors.
- Show different templates (if provided) for "next", "error", and "complete" notifications.
Our LetDirective is now fully implemented! All the core requirements are handled — time to take a moment and appreciate the work done!
Exploring Further
We've reached the end of this implementation, but there's still room for more! Some use cases might not be fully covered, and exploring them could benefit you and the community.
For instance, consider resetting the source Observable. If the Observable emits an error and we want to restart it, we could do so for the Observable itself, but the view wouldn't reset correctly without extra handling.
Feel free to experiment and come up with your own solutions!
Bonus Section – Adding Suspense
Interestingly, we can add "suspense" handling — showing a template before any value is emitted — with just a few extra lines.
What do we need? First, we add one more field to our TemplateRecord type for the "suspense" template:
type TemplateRecord<T> = {
complete: TemplateRef<LetViewContext<T>> | undefined,
error: TemplateRef<LetViewContext<T>> | undefined,
next: TemplateRef<LetViewContext<T>> | undefined,
suspense: TemplateRef<LetViewContext<T>> | undefined
};
Next, we add an input binding for this "suspense" template, just as we did for the others:
@Input()
set rxLetSuspense(templateRef: TemplateRef<LetViewContext<T>>) {
this.templateCache.suspense = templateRef;
}
Then, we display it when the view initializes:
ngOnInit(): void {
if (this.templateCache.suspense) {
this.displayView('suspense');
}
}
That's all there is to it! Now you can easily bind loading spinners or other placeholders.
The LetDirective and high-performant reactive rendering
To wrap things up, let's circle back to the topic I raised at the start of Part 1 — leveraging the LetDirective for high-performant rendering.
Unsurprisingly, smart folks had already explored the LetDirective's potential before I did, and they've put it to excellent use. The pinnacle of that effort is making high-performance reactive rendering a reality in Angular. This is achieved by building zone-less applications — and in that scenario, both the LetDirective and the PushPipe (introduced at the beginning of the first part) truly shine.
So, why would someone want a zone-less Angular app? The Angular team has done an outstanding job optimizing their framework with Zone.js and change detection. However, for large-scale applications, it's been demonstrated that Zone.js can become a bottleneck for developers — and unfortunately, for users as well. With a massive component tree, every single change detection cycle can mean traversing the whole tree and triggering checks in every component along the way, even when nothing has actually changed for those specific components. The result is a significant number of unnecessary re-renders.
If you're interested in learning more about creating reactive zone-less apps, achieving performant reactive rendering, and why this is a key direction for Angular's future core, check out this excellent talk:
In fact, there's a library that goes beyond the features we've implemented ourselves, offering even better performance through custom rendering strategies built on top of the LetDirective and the PushPipe — that library is RxAngular.
RxAngular provides exactly the tools for this — and then some. One particularly cool aspect is that it allows you to achieve the kind of performance gains you'd typically expect from a zone-less application, all without having to go zone-less yourself. Beyond just reactive rendering tools, it also offers a smart, ergonomic way to manage reactive local component state (see @rx-angular/state). Give it a look, and if you like it, star it on GitHub!
Wrapping up
Thank you for sticking with me through this deep dive into a complex subject. I hope you found the journey enjoyable and that this knowledge proves useful both to you and to others who work with RxAngular's LetDirective and the other features it offers.
Special thanks
I want to express my gratitude to a few wonderful individuals who took the time to introduce me to @rx-angular/template and to review these two articles ?
