Angular effect() and linkedSignal(): Advanced Signals

Angular Tutorial: Beginner to Advanced · 13. Advanced signals and data loading · 4 min read · Updated

You already know signal() and computed(). This lesson covers the two tools for harder cases: effect() for side effects and linkedSignal() for state that depends on another signal but can still be changed by the user.

What you will learn

  • What effect() is for, and when not to use it
  • How linkedSignal() creates resettable, writable state
  • How untracked() stops unwanted dependencies

effect(): run code when signals change

An effect runs once at the start and again whenever a signal it reads changes. Use it to connect signals to things outside Angular: saving to storage, logging, drawing on a canvas, or calling a non-Angular library.

// src/app/advanced/theme-saver-effect.ts
import { Component, effect, signal } from '@angular/core';

@Component({
  selector: 'app-theme-effect',
  template: `
    <button type="button" (click)="toggle()">Theme: {{ theme() }}</button>
  `,
})
export class ThemeEffect {
  protected readonly theme = signal<'light' | 'dark'>('light');

  constructor() {
    // Runs now, and each time theme() changes.
    effect(() => {
      document.documentElement.dataset['theme'] = this.theme();
      try {
        localStorage.setItem('theme', this.theme());
      } catch {
        // storage can be blocked
      }
    });
  }

  protected toggle(): void {
    this.theme.update((t) => (t === 'light' ? 'dark' : 'light'));
  }
}

Effects must be created in an injection context, such as a constructor or a field initializer. Angular destroys them together with the component.

### Do not use effect() to copy state

This is the most common mistake:

// Avoid: an effect that sets another signal
effect(() => {
  this.total.set(this.price() * this.quantity());
});

// Prefer: derived state with computed()
readonly total = computed(() => this.price() * this.quantity());

computed() is lazy, cached and easy to follow. Use an effect only when something outside the signal graph must happen.

linkedSignal(): derived but writable

Sometimes state depends on another signal, yet the user can change it. Example: a list of shipping options for a country, where the selected option must reset when the country changes.

// src/app/advanced/shipping-picker.ts
import { Component, linkedSignal, signal } from '@angular/core';

const OPTIONS: Record<string, string[]> = {
  India: ['Standard', 'Express'],
  Germany: ['Standard', 'Express', 'Pickup'],
};

@Component({
  selector: 'app-shipping-picker',
  template: `
    <label for="country">Country</label>
    <select id="country" (change)="country.set($any($event.target).value)">
      @for (c of countries; track c) {
        <option [value]="c">{{ c }}</option>
      }
    </select>

    <fieldset>
      <legend>Shipping</legend>
      @for (o of options(); track o) {
        <label>
          <input type="radio" name="ship" [checked]="selected() === o" (change)="selected.set(o)" />
          {{ o }}
        </label>
      }
    </fieldset>
    <p>Selected: {{ selected() }}</p>
  `,
})
export class ShippingPicker {
  protected readonly countries = Object.keys(OPTIONS);
  protected readonly country = signal('India');
  protected readonly options = linkedSignal(() => OPTIONS[this.country()]);

  // Starts as the first option, resets whenever options change,
  // but the user can still call selected.set(...).
  protected readonly selected = linkedSignal<string[], string>({
    source: this.options,
    computation: (list, previous) =>
      previous && list.includes(previous.value) ? previous.value : list[0],
  });
}

The long form takes a source, and a computation that also receives the previous value. Here we keep the old choice if it still exists, and fall back to the first option otherwise.

untracked(): read without depending

Inside an effect or computed, untracked() reads a signal without making it a dependency:

// src/app/advanced/search-logger.ts
import { Component, effect, signal, untracked } from '@angular/core';

@Component({
  selector: 'app-search-logger',
  template: `<input aria-label="Search" (input)="query.set($any($event.target).value)" />`,
})
export class SearchLogger {
  protected readonly query = signal('');
  private readonly clicks = signal(0);

  constructor() {
    effect(() => {
      const q = this.query(); // tracked: the effect re-runs when this changes
      const n = untracked(() => this.clicks()); // not tracked
      console.log(`Search "${q}" after ${n} clicks`);
    });
  }
}

The key parts

  • Prefer computed() for derived values. Use linkedSignal() for derived values the user can override. Use effect() only for side effects outside Angular.
  • Effects run asynchronously during change detection, not at the moment a signal is set.
  • Avoid setting signals inside effects. If you must, think again about computed or linkedSignal.
  • untracked() is the escape hatch for values you want to read but not follow.

Try it yourself

  1. Add a "reset" button to the shipping picker that sets selected back to the first option.
  2. Rewrite the theme effect so it also reads the saved theme at start-up.
  3. Find one place in your own code where an effect could be a computed().

Quick check

  1. When is effect() the right tool?
  2. What is different about linkedSignal() compared with computed()?
  3. What does untracked() do?

Next, load data that depends on signals with the resource API. See the signals guide for more.

Frequently asked questions

Can an effect run on the server?

Yes, effects run during server rendering too, so guard browser-only code such as document and localStorage, or use afterNextRender for browser work.

Why did Angular warn me about writing signals in an effect?

It is a sign of derived state that should be computed() or linkedSignal(). Writes inside effects can cause loops and hard-to-trace updates.