effect & linkedSignal
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:
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:
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
awaitor inside asetTimeoutcallback 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:
// 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:
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:
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());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:
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());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
effectto set other signals. UsecomputedorlinkedSignalinstead; 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 sayingeffect()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
onCleanupargument for timers, listeners and subscriptions. - Do not use effects to copy values between signals; prefer
computed. linkedSignalis 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
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()?
Effects are for side effects outside Angular's signal world; derived values belong in computed.
2. Where should you normally create an effect?
Effects need an injection context, which the constructor and field initializers provide.
3. What does linkedSignal(() => options()[0]) do when options changes?
A linked signal recomputes from its source whenever the source changes.
4. What is the onCleanup function in an effect for?
Cleanup callbacks run before the next run and when the effect is destroyed.
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
