Angular Advanced #2 Signals in depth — computed, effect, model

10 min read

In Basics #3 we built a counter with signal(), and in Intermediate #3 we wrapped up RxJS and even sketched how toSignal bridges the two worlds. So far we’ve treated signals as roughly “a reactive variable you call like a function to read.” In this post we’ll finally look inside Signals. computed, effect, plus the new face of the component API — input(), output(), model() — and even linkedSignal, all in one place.

Where Signals stand #

Signals landed in Angular 16 as a preview, stabilized in 17, and have since settled in as the standard reactive model of modern Angular. This isn’t only an Angular trend. SolidJS paved the way, Vue 3’s ref/computed follows the same shape, and Svelte 5’s $state/$derived runes are part of the same family. Frameworks are converging on the same direction as if by agreement.

The reason is simple. Signals make change detection precise and code easy to follow because the runtime knows exactly “what depends on what.” From Angular’s side, they reduce the burden of the long-standing Zone.js-based change detection and serve as a bridge to the future Zoneless mode.

signal() revisited — set, update, asReadonly #

Same as in Basics, but let’s go one level deeper.

src/app/counter.component.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Current: {{ count() }}</p>
    <button (click)="inc()">+1</button>
  `,
})
export class CounterComponent {
  private _count = signal(0);
  count = this._count.asReadonly();

  inc() {
    this._count.update(v => v + 1);
  }
}

Three things to remember:

  • set(value) swaps the value entirely; update(prev => next) takes the previous value and produces a new one.
  • asReadonly() lets you expose only a read-only signal to the outside. It’s exactly the same idea as RxJS’s Subject/asObservable() pattern.
  • The unit of change tracking for a signal is a reference. Modifying a field inside an object doesn’t notify the signal. You have to set a new object or update(prev => ({ ...prev, ... })) to swap it.

The last point is where you’ll stumble if you’re not used to it. A signal judges “changed” with === by default, so putting the same reference back in is not seen as a change.

computed() — derived values and memoization #

computed is a derived value that depends on other signals. It automatically recomputes only when a signal it depends on changes; otherwise it returns the cached value as is.

src/app/cart.component.ts
import { Component, computed, signal } from '@angular/core';

interface Item { name: string; price: number; qty: number; }

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    <p>Total: {{ total() }} ({{ count() }} items)</p>
    <p>With shipping: {{ totalWithShipping() }}</p>
  `,
})
export class CartComponent {
  items = signal<Item[]>([
    { name: 'Apple', price: 1000, qty: 3 },
    { name: 'Pear', price: 2000, qty: 2 },
  ]);

  count = computed(() => this.items().reduce((s, i) => s + i.qty, 0));
  total = computed(() => this.items().reduce((s, i) => s + i.price * i.qty, 0));
  totalWithShipping = computed(() => this.total() + (this.total() >= 10000 ? 0 : 3000));
}

totalWithShipping ultimately depends on items through total. While items is unchanged, calling it any number of times in the template runs the calculation only once. Dependencies are registered automatically the moment a signal is read inside the function body. You never have to write “track this” separately.

Just remember one rule — don’t cause side effects inside computed. HTTP calls, console logs, or set on other signals are absolutely off limits. Memoization means the call may be skipped or run multiple times, and that has to be fine.

effect() — attach side effects to signal changes #

When you do need a side effect, use effect. If computed “produces a value,” effect “does work.”

src/app/preferences.component.ts
import { Component, effect, signal } from '@angular/core';

@Component({ /* ... */ })
export class PreferencesComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    effect(() => {
      const t = this.theme();
      document.documentElement.dataset['theme'] = t;
      localStorage.setItem('theme', t);
    });
  }
}

effect runs once when registered, and after that re-runs whenever a signal it read in the body changes. Dependency tracking is automatic, just like in computed.

effect must be tied to the injection context of a component (or directive, service) by default. So you usually call it in the constructor or in field initialization. When the component is destroyed, it cleans itself up — no unsubscribe to chase like in RxJS.

If you need cleanup, take it through the callback’s argument.

effect with cleanup
constructor() {
  effect(onCleanup => {
    const id = setInterval(() => console.log(this.count()), 1000);
    onCleanup(() => clearInterval(id));
  });
}

The function registered with onCleanup is called right before the next run and at component destruction. You only need to care when there are resources you touch directly, like setInterval/addEventListener.

Note
effect is not a lifecycle hook. Don’t use it for “one-time initialization” like ngOnInit. The job of an effect is strictly signal → outside world — localStorage sync, logging, syncing with an external library, and so on.

Signal-based component API — input, output, viewChild #

From Angular 17.1 there’s a new API that declares component inputs and outputs as functions instead of decorators. The shape pairs naturally with signals.

src/app/user-card.component.ts
import { Component, input, output, computed } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <h3>{{ greeting() }}</h3>
    <button (click)="select.emit(name())">Select</button>
  `,
})
export class UserCardComponent {
  name = input.required<string>();
  prefix = input<string>('-san');

  select = output<string>();

  greeting = computed(() => `${this.name()}${this.prefix()}, welcome!`);
}

The contrast is clear.

Decorator-basedSignal-based
Input@Input() name!: string;name = input.required<string>();
Output@Output() select = new EventEmitter<string>();select = output<string>();
Child reference@ViewChild('x') x!: ElementRef;x = viewChild<ElementRef>('x');
Usethis.namethis.name()

The biggest win of the new API is that inputs are signals. You can derive values naturally with computed, and an input the parent changes flows straight into the child’s effect. Change detection runs consistently on top of the signal graph.

input.required<T>() means the parent must pass a value, and missing it is caught as a compile error. The slot where decorator-era code had to fool the type with ! is now clean.

model() — two-way binding gets clean in standalone components #

[(ngModel)] was trapped inside forms, but plain components sometimes need two-way binding too. You used to pair @Input value + @Output valueChange by hand; model() wraps that into one line.

src/app/toggle.component.ts
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-toggle',
  standalone: true,
  template: `
    <button (click)="flip()">{{ checked() ? 'On' : 'Off' }}</button>
  `,
})
export class ToggleComponent {
  checked = model.required<boolean>();

  flip() {
    this.checked.update(v => !v);
  }
}

The parent connects two-way with one line of [(checked)].

parent.component.html
<app-toggle [(checked)]="isOn" />

Internally model is a signal that combines input + output + writable. When the child mutates the value with update/set, the parent’s signal updates along with it. No FormsModule, no ngModel.

linkedSignal — a local processed signal linked to an input (Angular 19+) #

Sometimes a child should start from a parent’s input value, let the user edit it, and reset back when the parent sends a new value down — the place you used to wire by hand with ngOnChanges. linkedSignal collapses that into one line.

src/app/editable-name.component.ts
import { Component, input, linkedSignal } from '@angular/core';

@Component({ /* ... */ })
export class EditableNameComponent {
  initialName = input.required<string>();

  draft = linkedSignal(() => this.initialName());

  reset() { this.draft.set(this.initialName()); }
}

draft behaves like a regular writable signal, but when initialName changes, it re-syncs to that value automatically. It’s a hybrid that tracks dependencies like computed while being writable like signal.

Cooperation between Signals and RxJS — toSignal / toObservable #

The picture I sketched in Intermediate #3 comes together here. The two worlds are not rivals — they each have their own sweet spot.

src/app/users.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
import { UserService } from './user.service';

@Component({ /* ... */ })
export class UsersComponent {
  private users = inject(UserService);

  query = signal('');
  // signal → Observable → HTTP → signal
  results = toSignal(
    toObservable(this.query).pipe(
      switchMap(q => this.users.search(q)),
    ),
    { initialValue: [] },
  );

  count = computed(() => this.results().length);
}

The flow reads simply.

  1. query is a signal. When the input changes, it reacts immediately.
  2. toObservable pipes the signal’s changes into an Observable stream.
  3. switchMap handles the async search in RxJS’s domain — even canceling the previous request in one line.
  4. The result becomes a signal again with toSignal and flows into the template and into computed.

The guidance is simple — UI state and derived values are signals; async streams and operator combinations are RxJS. The two converters bridge at the boundary.

untracked and equality — fine-grained control #

Sometimes you have a signal you want to read without creating a dependency. Inside an effect, you might want to read another signal without re-running the effect every time it changes. That’s where untracked comes in.

Cutting dependency with untracked
import { effect, untracked } from '@angular/core';

constructor() {
  effect(() => {
    const id = this.userId();           // tracked
    const region = untracked(() => this.region()); // read only
    this.fetchProfile(id, region);
  });
}

This way, effect re-runs only when userId changes, while region is read at its current value without being tracked.

When you want to swap the comparison function, use the equal option.

Deep object comparison
import { signal } from '@angular/core';
import { isEqual } from 'lodash-es';

const filter = signal({ q: '', tag: null }, { equal: isEqual });

With deep comparison instead of the default ===, setting a new object of the same shape is not seen as a change. You can avoid unnecessary re-runs of downstream computed/effect. Don’t slap deep comparison on every signal — apply it selectively where re-runs actually bother you.

Common mistakes #

Three traps you’ll commonly hit when you first start using signals seriously.

1. Calling set on a signal inside effect. It’s blocked by default. If you really need it, you can unblock with { allowSignalWrites: true }, but 99% of the time what you actually need isn’t that — it’s computed.

2. Producing side effects inside computed. computed is memoized, so the number of executions isn’t guaranteed. Both console.log and set on other signals are absolutely off limits. Put side effects in effect.

3. Mutating an object directly.

Bad vs good
this.user().name = 'New name'; // ❌ signal doesn't know
this.user.update(u => ({ ...u, name: 'New name' })); // ✅ new reference

The signal recognizes a change only when the reference changes. Immutable updates are the answer.

Tip
When you start using signals, build the habit of asking not “how do I change the value?” but “is this a signal, a computed, or an effect?” If it falls cleanly into one of the three, your code stays clean. When you can’t decide where to put it, the answer is almost always computed.

Wrap-up #

In this post we dug into Signals seriously.

  • signal is the unit of change tracking. set/update/asReadonly
  • computed is an automatically tracked derived value. No side effects, memoized
  • effect is signal → outside world. Supports cleanup callbacks, not a lifecycle hook
  • input()/output()/model()/viewChild() integrate the component API on top of signals
  • model() makes two-way binding clean in standalone components
  • linkedSignal is a local writable signal linked to an input (Angular 19+)
  • toSignal/toObservable move freely between signals and RxJS
  • untracked cuts dependencies, equal customizes the comparison

The next post is “Angular Advanced #3 Dynamic components and Portal”. We’ll cover the dynamic component API for creating components at runtime — modals, tooltips, overlays — and Angular CDK’s Portal/Overlay.

X