Skip to main content

5 min read #angular #templates #control-flow #signals #frontend

@let ended my async-pipe pyramid

Reusing a value in a template used to mean piping it three times or abusing *ngIf 'as' for scope. @let declares a real template variable — reactive, read-only, scoped to where you put it — and the workarounds retire.

Every Angular template has, at some point, contained this crime:

<h1>{{ (user$ | async)?.name }}</h1>
<p>{{ (user$ | async)?.email }}</p>
<img [src]="(user$ | async)?.avatar" />

Three subscriptions to the same observable, three independent async pipes, all to render one user. The "fix" we all learned was to abuse *ngIf for its aliasing side effect — *ngIf="user$ | async as user" — subscribing once and binding the result to a local name. It worked, but it was a hack: you were using a structural directive whose job is conditional rendering purely because it happened to create a scoped variable, and the whole block vanished the instant the value was falsy.

@let is the feature that should have existed the whole time. It declares a template variable. That's it.

@let user = user$ | async;

<h1>{{ user?.name }}</h1>
<p>{{ user?.email }}</p>
<img [src]="user?.avatar" />

One pipe, one subscription, a name you reuse everywhere below it. No @if wrapper forced on you, no directive doing a job it wasn't designed for.

It's reactive, and it's read-only

Two properties make @let behave the way you'd hope. It's reactive: the right-hand side is re-evaluated when its dependencies change, so @let count = counter() tracks the signal, and the async-pipe version updates on every emission. You're naming a live value, not snapshotting one.

And it's read-only. You cannot reassign a @let from the template — there's no user = somethingElse. This trips people who expect a JavaScript let, but it's the right call: a template is a declaration of what the UI is, not a place to run imperative logic. If you need to change a value, you change the state it derives from, and the @let follows.

Scope: after the line, inside the view

A @let is available from its declaration onward, within the same view and any nested views below it. Two consequences worth internalizing. You can't reference it before you declare it — order matters, top to bottom. And it doesn't leak upward or sideways: a @let declared inside an @if block or an @for loop exists only in there. That last bit is actually useful — inside a loop, @let rowTotal = ... gives each iteration its own value without you thinking about it.

It does not replace @if for null-narrowing

This is the one mistake to avoid. @let names a value; it does not guarantee the value. An async pipe yields null before its first emission, so @let user = user$ | async is null on the first render. If the template below assumes a user exists, you'll be writing user?. everywhere or hitting null reads.

So the two features pair rather than compete. @if still does the narrowing; @let can carry the loaded value or a derived one:

@if (user$ | async; as user) {
  @let initials = user.firstName[0] + user.lastName[0];
  <span class="avatar">{{ initials }}</span>
  <h1>{{ user.name }}</h1>
}

The @if guarantees user is present and non-null inside the block; the @let gives you a clean name for a value you'd otherwise compute inline twice.

When to use a computed instead

@let is for the view. The moment a derived value is needed in TypeScript too — in a method, in another computed, in a guard — it belongs in a computed() in the component, not in the template. The smell is duplication: if you'd write the same @let expression in two separate templates, or you need it outside the template at all, promote it to computed() and bind to that. Use @let for the genuinely view-local stuff: aliasing a long property path, naming an async result, giving a loop iteration a readable per-row total. For that, it's exactly the right amount of power — and it finally retires the *ngIf as hack I'd been apologizing for in code review for years.