EthicsComputerCourses
Join training +91 90532 10052

effect & linkedSignal

Angular · Lesson 9 of 41 Intermediate 7 min read Updated

signal() holds state and computed() derives values from it. Two more tools complete the set. effect() runs code because signals changed, for jobs outside Angular such as saving to localStorage. linkedSignal() creates a writable value that resets itself when another signal changes, which is perfect for "the selected option" in a list that can change.

Both are stable in Angular 22.

effect(): reacting to changes

An effect is a function that runs once at the start and then again whenever any signal it read changes. Angular tracks the reads automatically, just as it does for computed.

Here a canteen cart saves itself to the browser's localStorage, so the cart survives a page refresh, and keeps the browser tab title up to date:

TypeScript
import { Component, computed, effect, signal } from '@angular/core';

@Component({
  selector: 'app-saved-cart',
  template: `
    <p>{{ items().length }} items, ₹{{ total() }}</p>
    <button (click)="add('Vada Pav', 25)">Add Vada Pav</button>
  `,
})
export class SavedCart {
  protected items = signal<{ name: string; price: number }[]>(
    JSON.parse(localStorage.getItem('cart') ?? '[]'),
  );
  protected total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));

  constructor() {
    effect(() => {
      localStorage.setItem('cart', JSON.stringify(this.items()));
    });

    effect(() => {
      document.title = `Cart (${this.items().length})`;
    });
  }

  protected add(name: string, price: number) {
    this.items.update((list) => [...list, { name, price }]);
  }
}

After clicking the button twice, the tab title reads "Cart (2)" and the browser storage contains:

Output
cart = [{"name":"Vada Pav","price":25},{"name":"Vada Pav","price":25}]

Some important rules:

  • Where to create it. Create effects in a constructor or a field initializer. These run in Angular's injection context, which lets the effect be tied to the component. When the component is destroyed, its effects stop automatically.
  • When it runs. An effect does not run immediately when you call set(). Angular runs it shortly afterwards, as part of its update cycle. Several changes in a row lead to a single run with the latest values.
  • What it tracks. Only signals read synchronously inside the function are tracked. A signal read after an await or inside a setTimeout callback is not a dependency.

When not to use effect

Effects are the tool of last resort. The most common misuse is copying one signal into another:

TypeScript
// Avoid: an effect that only keeps two signals in sync
effect(() => {
  this.discount.set(this.total() > 500 ? 50 : 0);
});

// Better: a computed value
protected discount = computed(() => (this.total() > 500 ? 50 : 0));

The effect version is harder to follow, runs one step later than the value it depends on (so for a moment the screen can show an old discount) and is easy to break. Use effects only for side effects: talking to localStorage, logging, analytics, or calling a non-Angular library such as a chart or map. If the result is a value, use computed; if it is a value the user can also change, use linkedSignal.

Cleaning up

Some effects start something that must be stopped, such as a timer. The effect function receives an onCleanup function. The cleanup you register runs before the next run and when the effect is destroyed:

TypeScript
import { Component, effect, signal } from '@angular/core';

@Component({
  selector: 'app-exam-timer',
  template: `
    <p>Time left: {{ seconds() }} s</p>
    <button (click)="running.set(!running())">{{ running() ? 'Pause' : 'Start' }}</button>
  `,
})
export class ExamTimer {
  protected seconds = signal(600);
  protected running = signal(false);

  constructor() {
    effect((onCleanup) => {
      if (!this.running()) {
        return;
      }
      const id = setInterval(() => this.seconds.update((s) => Math.max(0, s - 1)), 1000);
      onCleanup(() => clearInterval(id));
    });
  }
}

When running changes from true to false, Angular first calls the cleanup, which clears the interval, then runs the effect again, which returns early. Without the cleanup, every Start click would add another interval and the clock would run faster and faster.

For work that must touch the DOM after Angular has rendered it (measuring an element's size, focusing an input), Angular also provides afterRenderEffect(), covered in Lifecycle Hooks.

linkedSignal(): writable state that follows a source

Imagine a page where a student picks a slot for a lab exam. The available slots come from a signal, and the selected slot should default to the first one. The user must be able to change the selection, but when the list of slots changes (a new date is picked), the selection must reset.

A computed cannot be changed by the user. A plain signal does not reset. linkedSignal does both:

TypeScript
import { linkedSignal, signal } from '@angular/core';

const slots = signal(['9:00 am', '11:00 am', '4:00 pm']);
const chosen = linkedSignal(() => slots()[0]);

console.log(chosen());
chosen.set('4:00 pm');      // the user picks a slot
console.log(chosen());
slots.set(['10:00 am', '2:00 pm']); // a new date: new slots
console.log(chosen());
Output
9:00 am
4:00 pm
10:00 am

chosen behaves like a writable signal (it has set() and update()), but whenever slots changes, it recomputes from the function and throws away the manual choice.

Keeping the previous choice when possible

Sometimes the old choice is still valid after the source changes, and resetting would annoy the user. The longer form of linkedSignal gives you the previous value:

TypeScript
const sizes = signal(['S', 'M', 'L']);
const size = linkedSignal({
  source: sizes,
  computation: (newSizes, previous) =>
    previous && newSizes.includes(previous.value) ? previous.value : newSizes[0],
});

size.set('L');
sizes.set(['M', 'L', 'XL']);
console.log(size());
sizes.set(['XS', 'S']);
console.log(size());
Output
L
XS

previous is undefined the first time, and afterwards holds { source, value }: the old source and the old value. Here the shirt size "L" survives the first change because it is still available, and resets to "XS" when it is not.

Common mistakes

  • Using effect to set other signals. Use computed or linkedSignal instead; effects are for side effects outside the signal world.
  • Creating an effect in a click handler or ngOnInit. Outside the injection context Angular throws an error saying effect() can only be used within an injection context. Create it in the constructor or as a field.
  • Forgetting cleanup. Timers, subscriptions and event listeners started in an effect must be stopped with onCleanup.
  • Expecting effects to run instantly. They run a little later, during Angular's update cycle, not inside set().

Key points

  • effect(() => ...) runs once and again whenever the signals it reads change; use it for side effects only.
  • Create effects in a constructor or field initializer; they stop when the component is destroyed.
  • Register cleanup with the onCleanup argument for timers, listeners and subscriptions.
  • Do not use effects to copy values between signals; prefer computed.
  • linkedSignal is a writable signal that resets when its source changes; the { source, computation } form can keep the previous value.

Exercise

A food delivery page shows restaurants in a city. Write a component with a signal city ("Lucknow"), a computed restaurants that returns a list for the city (use a small object map with two cities), and a linkedSignal selected that defaults to the first restaurant in the list. Add an effect that logs "Selected: X" whenever selected changes. Add buttons to switch city and to pick the second restaurant.

Show solution
TypeScript
import { Component, computed, effect, linkedSignal, signal } from '@angular/core';

const RESTAURANTS: Record<string, string[]> = {
  Lucknow: ['Tunday Kababi', 'Royal Cafe'],
  Kochi: ['Kayees Rahmathulla', 'Fort House'],
};

@Component({
  selector: 'app-restaurant-picker',
  template: `
    <p>City: {{ city() }} | Selected: {{ selected() }}</p>
    <button (click)="city.set(city() === 'Lucknow' ? 'Kochi' : 'Lucknow')">Switch city</button>
    <button (click)="selected.set(restaurants()[1])">Pick second</button>
  `,
})
export class RestaurantPicker {
  protected city = signal('Lucknow');
  protected restaurants = computed(() => RESTAURANTS[this.city()] ?? []);
  protected selected = linkedSignal(() => this.restaurants()[0]);

  constructor() {
    effect(() => console.log(`Selected: ${this.selected()}`));
  }
}

Clicking "Pick second" logs "Selected: Royal Cafe". Switching city resets the selection to the new city's first restaurant, and the effect logs that too. The effect only logs; it never writes to another signal.

Quick Quiz

1. Which job is a good use of effect()?

2. Where should you normally create an effect?

3. What does linkedSignal(() => options()[0]) do when options changes?

4. What is the onCleanup function in an effect for?

Learn Angular with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call