Templates & LogicExtra· 40 min read

Signals & Modern Control Flow (@if / @for)

Signals are Angular’s new, simpler way to store changing values — and @if / @for are the modern replacements for *ngIf and *ngFor.

What you will learn

  • Create a value that updates the screen with signal()
  • Derive new values with computed()
  • Use the new @if and @for blocks in templates

A smarter kind of variable

A signal is a special box that holds a value and tells Angular whenever that value changes. When a signal changes, Angular updates only the parts of the screen that use it — no guessing, no extra work. Signals arrived in Angular 16 and are the default reactivity model from Angular 17 onward, so new projects use them everywhere.

You already know a plain property like count = 0. A signal is the same idea, but you create it with signal(...), read it by calling it like a function count(), and change it with set or update.

Create and read a signal

First make a counter signal. Notice you call it — count() with parentheses — to read the value.

A signal created with signal(0)
// counter.component.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: '<p>Count: {{ count() }}</p>'
})
export class CounterComponent {
  count = signal(0);            // a signal starting at 0
}

Note: Output: Count: 0 The template reads the value with count() — the parentheses are important, because a signal is read like a function call. Right now it shows 0.

Change a signal with set and update

There are two ways to change a signal. Use set(...) to put a brand-new value in, or update(...) to change it based on the current value.

update() changes from the old value; set() replaces it
// in the class
increase() {
  this.count.update(n => n + 1);   // based on the old value
}
reset() {
  this.count.set(0);               // a brand-new value
}

Note: Output: (No visible output by itself.) update(n => n + 1) reads the current count and adds one; set(0) simply puts 0 back. Wire increase() to a button and the Count: line on screen updates by itself, because Angular is watching the signal.

computed(): values built from other signals

A computed signal is a value worked out from other signals. It recalculates by itself whenever any signal it depends on changes — you never update it by hand.

total is derived from price and quantity
import { signal, computed } from '@angular/core';

price = signal(250);
quantity = signal(3);
total = computed(() => this.price() * this.quantity());

Note: Output (when read with {{ total() }}): 750 total is 250 × 3 = 750. Change quantity to 4 with quantity.set(4) and total instantly becomes 1000 — without you touching total at all. That is the magic of computed signals.

effect(): run code when a signal changes

A computed signal makes a new value. Sometimes instead you want to run an action whenever a signal changes — log it, save it to localStorage, or update something outside Angular. That is what an effect is for: a block of code that re-runs automatically every time any signal it reads changes. You set one up once, in the constructor.

effect() reacts to signal changes with an action
import { Component, signal, effect } from '@angular/core';

@Component({ selector: 'app-counter', standalone: true, template: '...' })
export class CounterComponent {
  count = signal(0);

  constructor() {
    effect(() => {
      // re-runs whenever count changes
      console.log('Count is now', this.count());
    });
  }
}

Note: Output (as count goes 0 → 1 → 2): Count is now 0 Count is now 1 Count is now 2 The effect runs once right away (logging 0), then again every time count changes — because it reads count() inside, Angular knows to re-run it. Use computed when you want a new value; use effect when you want to do something (log, save, sync) on each change.

Watch out: A computed must be pure (just return a value, no side effects). Do your side effects — logging, saving, calling other code — inside an effect instead, not inside a computed.

Signal inputs: a child @Input as a signal

You will meet @Input() for passing data from a parent into a child component. Modern Angular adds a signal input with input(): the value the parent passes arrives as a signal, so you read it with name() and it plugs straight into computed and effect.

input() exposes a parent value as a signal
import { Component, input, computed } from '@angular/core';

@Component({ selector: 'app-greeting', standalone: true,
  template: '<p>{{ shout() }}</p>' })
export class GreetingComponent {
  name = input('friend');                      // a signal input (default 'friend')
  shout = computed(() => this.name().toUpperCase() + '!');
}

Note: Output (when a parent sets <app-greeting [name]="'asha'">): ASHA! The parent passes name exactly like a normal @Input, but inside the child it is a signal — read with name(). Because shout is a computed that reads name(), it updates by itself whenever the parent changes the name. (You meet plain @Input() in the component-communication lesson; input() is its signal-based version.)

The new control flow: @if and @for

Angular 17 also introduced a cleaner way to write conditions and loops directly in the template, called built-in control flow. It replaces *ngIf with @if and *ngFor with @for. The old directives still work, but new code uses these blocks — they are easier to read and faster.

The modern @if and @for blocks
<!-- @if replaces *ngIf -->
@if (count() > 0) {
  <p>You have {{ count() }} items.</p>
} @else {
  <p>Your cart is empty.</p>
}

<!-- @for replaces *ngFor (track is required) -->
<ul>
  @for (fruit of fruits; track fruit) {
    <li>{{ fruit }}</li>
  }
</ul>

Note: Output (when count() is 2 and fruits is [Apple, Banana]): You have 2 items. • Apple • Banana The @if block shows one branch or the @else branch. The @for block loops like *ngFor, but you must add track fruit so Angular can tell rows apart and update the list efficiently.

@switch: pick one of several cases

When a value can be one of several options, a stack of @if blocks gets messy. @switch is the cleaner choice — it checks a value against each @case and shows the matching block, falling back to @default if none match. It is the template version of JavaScript’s switch statement.

The @switch block chooses one branch by value
@switch (status()) {
  @case ('loading') { <p>Loading…</p> }
  @case ('error')   { <p>Something went wrong.</p> }
  @default          { <p>All done!</p> }
}

Note: Output (when status() is 'loading'): Loading… Angular reads status() and shows the block whose @case matches. Set the signal to 'error' and it swaps to “Something went wrong.”; any other value shows the @default “All done!” branch. Only one branch is ever on the page at a time.

Old wayModern way (Angular 17+)
*ngIf="x"@if (x) { }
*ngIf; else@if (x) { } @else { }
*ngFor="let i of list"@for (i of list; track i) { }
[ngSwitch] / *ngSwitchCase@switch (x) { @case (..) { } @default { } }
a plain count = 0a count = signal(0)

Watch out: Two beginner traps: (1) read a signal with parenthesescount(), not count. (2) every @for block must include track, or Angular shows an error.

Tip: Signals + @if/@for are the direction Angular is heading. Learn the older *ngIf/*ngFor too (you will see them in existing code), but reach for signals and the new blocks in fresh projects.

Q. How do you read the value of a signal called count in a template?

Answer: A signal is read by calling it: count(). Forgetting the parentheses is the most common beginner mistake with signals.

✍️ Practice

  1. Make a likes signal starting at 0 and a button that calls likes.update(n => n + 1).
  2. Add a doubled = computed(() => this.likes() * 2) and show it next to the likes.

🏠 Homework

  1. Rebuild the *ngIf/*ngFor toggle-and-list example from earlier lessons using a signal plus @if and @for blocks.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →