The last two stops kept the whole circulation of news inside the Academy walls. A signal holds a value, the scribe from
computed() works that value into a number for the board, the template shows both. @name, all of it happens in one sealed circuit: a value goes in, a value comes out, and at the end somebody looks at the wall.Sooner or later, though, you will run into a task that does not fit inside that circuit. The Academy chronicler is supposed to write down every change in the number of patrols on the road, so that years later somebody can reconstruct what happened. The interface theme a student picks is supposed to survive the browser being closed, so it has to be put away somewhere. The gate watch is supposed to send a runner to the neighbouring castle when the alarm crosses a threshold. A map drawn by a foreign library that has never heard of Angular is supposed to redraw itself when the selected trail changes.
None of those tasks is a value. You cannot work them out and return them, because they have no result - they simply do something outside the application: they add a line to a journal, they write into the browser's memory, they send a request, they wake up foreign code. Programmers call actions like these side effects: everything a function does over and above returning a result.
The scribe from the previous lesson is a terrible fit for this, and it is worth knowing why, because the temptation is real. First,
computed() has to return something - that is its entire job. Second, and this is the decisive reason, the scribe works lazily: at the last stop we checked that the function handed to computed() does not run even once until somebody reads the result. A chronicle kept by the scribe would therefore have entries only on the days when somebody happened to glance at the board, and on a day when nobody looked at the wall the book would stay empty. What you need is somebody who sets off on his own, without being asked, and who is allowed to walk out through the gate.That somebody is an effect, and you post one by calling the
effect() function from the @angular/core package - the same package you take signal(), computed() and the @Component decorator from. You give it one argument: a function containing whatever is supposed to happen. In its simplest form that function takes no parameters at all, and the one useful parameter it may take you will meet near the end of the lesson. Angular calls it for you, in two different situations. Once at the start, right after the effect is created, so that the journal agrees with the state of the towers from the very first moment. And then again after every change to any signal read inside it. You report nothing, you subscribe to nothing, there is no list of dependencies to write out by hand - it is enough that you call a signal with parentheses somewhere in there.Let us begin with the smallest possible effect, just to see the shape of the call. Suppose you have a signal called
count somewhere, and the only thing you want is to print its value to the console after every change.1effect(() => console.log(count()));That single line is a complete effect, with nothing missing from it. Memorise it as four pieces laid down in an order that never varies: the name
effect, then the opening of the call together with the argument-less arrow, which is (() =>, then the expression console.log(count()) itself, and at the end the closing ). The body of the function here is a single expression, so it needs no braces - this is the same short form of an arrow function you know from computed(). Notice what is not in that line. There is no signal name listed off to the side as a dependency, there is no subscribe, there is no this.detectChanges() anywhere. Angular will learn that the effect is watching count from one source alone: that on the first run somebody read that signal.There is one thing the line will not put up with, though: you may not post it just anywhere. An effect has to be created inside an injection context - that is, somewhere Angular knows whose lifetime to tie it to. In practice that means a class constructor or a field initialiser, exactly the same places where you called
inject() back in the module about guilds. I checked on running code what happens outside them: the call ends with error NG0203, saying that effect() can only be used within an injection context such as a constructor, a factory function or a field initializer.So let us build a real chronicler. The component keeps an eye on a single number: how many patrols have been sent out onto the road. We will call the signal
patrols and it starts at zero. The sendPatrol() method raises it by one and uses update() from the previous lesson, because the new value follows from the old one. The only new thing is the constructor, and inside it a single effect() call that writes the next state into the journal. The rest of the code you know from the first module: the @Component decorator with a selector, the mark of a self-contained component and a template, plus a (click) event binding.1import { Component, signal, effect } from '@angular/core';
2
3@Component({
4 selector: 'app-patrol-log',
5 standalone: true,
6 template: '<button (click)="sendPatrol()">Send patrol</button>'
7})
8export class PatrolLogComponent {
9 patrols = signal(0);
10
11 constructor() {
12 effect(() => console.log('Patrols on the road:', this.patrols()));
13 }
14
15 sendPatrol(): void {
16 this.patrols.update(v => v + 1);
17 }
18}I ran this component and the sequence is exactly as promised. Right after the first render, before anyone has touched the button, an entry with a zero shows up in the journal. This matters and it often takes people by surprise: an effect runs at least once entirely on its own, because otherwise the journal would begin at the first change and you would never know what it started from. After a click on the button a second entry arrives, with a one in it.
The most interesting part is what stayed untouched. The
sendPatrol() method is identical to the ones in components from the previous stops - it raises the signal by one and stops there, it knows nothing about the chronicler's existence and does not contain a single line about the journal. The patrols signal did not change either: it is still an ordinary writable signal, you still read it with parentheses, you can still show it in the template. No lifecycle method from the second module appeared along the way - there is no ngOnInit here and nothing that has to be called by hand. The effect clipped itself on from the side and watches.That one-sidedness is a feature, not a shortcoming. The signal does not know how many observers it has, and it does not need to; adding a second, a third and a tenth effect reading
patrols requires no change in the signal whatsoever. The tower from the first lesson worked the same way: the guard lights the smoke and goes back to his own business, and who happens to be watching that smoke is an entirely separate story.Since Angular builds the list of dependencies out of what the effect actually read, that list is put together afresh on every run. The consequences can be surprising, so let us look at them on an example. Let us add a second signal to the component,
chronicleOpen, a flag saying whether the book is open at this moment. When it is shut, the chronicler writes nothing down - and therefore does not look over at the patrol tower either.1constructor() {
2 effect(() => {
3 if (this.chronicleOpen()) {
4 console.log('Patrols on the road:', this.patrols());
5 }
6 });
7}I checked this on a running effect and the result leaves no room for doubt. As long as
chronicleOpen returns true, every change to patrols runs the effect again, because on the last run that signal was read. But set chronicleOpen to false and the condition stops letting execution inside - and from that moment on, changes to patrols do not wake the effect a single time, because on the last run nobody read that tower. A dependency is not assigned to a signal once and for all; it is a record of what the effect looked at the last time round.Nothing changed in the signals themselves along the way - both towers are standing, both hold current values, and reading
this.patrols() anywhere else in the program still gives back the true number. The only thing asleep is the chronicler.Three more behaviours are worth knowing, all of them checked on running code, because they will save you hours of puzzlement. First, writing the same value a signal already held does not run the effect again - Angular compares the new value with the old one and wakes nobody when there is no difference. Second, if you change two signals the effect watches within a single turn, it runs once, not twice; the changes are collected and settled together. Third and sneakiest: only reads performed immediately in the body of the effect are tracked. A signal read inside a
setTimeout, in a response from the network, or in another function called later on will not become a dependency, because at the moment Angular was watching the run, nobody had called it yet.Now for the most common mistake everybody makes on first contact with effects. The component is supposed to show the number of guards on duty, and that number follows from two towers: from the number of patrols and from the strength of a single patrol. Since an effect runs after every change, it is tempting to use it to do the arithmetic and write the result into a third signal.
1// DO NOT DO THIS - an effect standing in for a derived value
2guardsOnDuty = signal(0);
3
4constructor() {
5 effect(() => {
6 this.guardsOnDuty.set(this.patrols() * this.guardsPerPatrol());
7 });
8}I will admit it honestly: this code works. I checked it on a running effect in Angular 19 - writing to a signal from inside an effect goes through without an error, and
guardsOnDuty gets the correct number and updates after every change to its ingredients. And yet it is the wrong solution, and I want you to know precisely why, because "it works" and "it is good" are two different things.There are three reasons. The first: you now have two sources of truth instead of one. The number of guards lives in a separate tower that anybody can overwrite at any moment with a
set() call from a completely different place, and nothing forbids it. The second: the result is out of date for a moment. The effect does not run in the same millisecond as the write to patrols, so there is an instant in which there are already more patrols and still as many guards as before. The third: for the correct value to appear, somebody has to write it into the signal - which means you have added a step to the application that has to be remembered. The whole lesson about the scribe was about not having steps like that.The proper solution takes one line, and you know it from the last stop.
1guardsOnDuty = computed(() => this.patrols() * this.guardsPerPatrol());There is nothing to keep an eye on here. The value works itself out from the two towers, nobody ever sets it and nobody can overwrite it, because a derived value simply does not have a
set() method. There is no window of disagreement either: reading guardsOnDuty() always gives a number matching the current state of the ingredients. My advice, @name, is unconditional on this point: if what you want to do can be phrased as "a result computed from signals", then it is not a job for an effect but for computed(). You reach for an effect only when it is not about a result at all, but about an action outside the application.This is also where the answer lies to a question that likes to come up: no, an effect is not there for combining several signals into one. Combining is precisely what
computed() is for, because it returns a value. The function handed to effect() has no way of returning anything - its result goes nowhere and nobody reads it, and the effect() call itself gives back a handle to the effect, not a number.Sometimes an effect does not merely write something down but starts something that keeps on living: a timer, an open connection, a listener inside a foreign library. That is when a problem appears which no amount of care alone can dodge. Since the effect runs again after every change, by the tenth change ten timers would be running at once, each from a different run, and none of them aware of the others.
That is why the effect function may take one parameter - Angular passes it a function for registering cleanup. It is conventionally called
onCleanup, and you call it giving it your own function with the tidying instructions. Angular will run that function right before the next run, and also at the end of the effect's life. In the example below a sentry repeats a report about the number of patrols once a second. For the timing I will use the browser function setInterval, which calls a given function every so many milliseconds and returns a handle to itself, together with clearInterval, which switches that handle off. Notice that I read the number of patrols before starting the timer and keep it in an ordinary constant.1effect((onCleanup) => {
2 const current = this.patrols();
3 const timer = setInterval(() => console.log('Patrols on the road:', current), 1000);
4
5 onCleanup(() => clearInterval(timer));
6});Let us follow the whole cycle in order, because it is one of those sequences worth being able to recite, and I checked it on a running effect step by step. First the effect reads a signal and registers it as a dependency - that happens on the line with
this.patrols(). Then it runs the actual side effect, meaning setInterval, and while it is at it declares how to clean up afterwards. Next somebody sends another patrol and the change to the signal triggers the effect to run again. And only then, as the very first act of that new run, the function from onCleanup executes and puts out the previous resource with a clearInterval call, and right after it the body of the effect starts over with the current number. In the journal it looks like a strip of tape: body for state one, cleanup after state one, body for state two, cleanup after state two, body for state three. Never two timers at once.Nothing changed along the way in how the signal itself behaves or in how many times the effect runs - cleanup is not an extra run and does not trigger anything by itself. Pay attention to that
current constant as well. I read the signal immediately in the body of the effect precisely because, as you know from the previous section, a read performed later, inside setInterval, would not become a dependency - the effect would never renew itself, and the sentry would go on repeating the first report until the end of time.Since an effect, once posted, works round and round, it is natural to ask who finally switches it off. The answer is more convenient than you expect: nobody, because Angular does it itself. An effect created in a constructor or in a field initialiser is tied to the life of that component and dies together with it. I checked this on a running component: after destroying it I sent out two more patrols and not a single new entry appeared in the journal, even though the code contained not one line devoted to cleaning up.
This is worth remembering separately, because in older examples around the web you will meet components that police the destruction of an effect by hand with
DestroyRef. Code like that is not wrong, but it is redundant - it repeats what Angular will do anyway, and it teaches a beginner the reflex of writing rituals for no reason. The Angular 19 documentation states it outright next to the manualCleanup option: when you do not pass it, the effect registers itself for cleanup.Sometimes, though, you want to silence it earlier, while the component is still alive - for instance when the student closes the book and further entries are nothing but noise. For that there is the handle returned by the
effect() call itself. In the Angular documentation its type is called EffectRef, and the only thing it can do is a destroy() method taking no arguments.1export class PatrolLogComponent {
2 patrols = signal(0);
3
4 private logRef = effect(() => console.log('Patrols on the road:', this.patrols()));
5
6 closeChronicle(): void {
7 this.logRef.destroy();
8 }
9}The effect sits in a field initialiser here rather than in the constructor, and that is perfectly allowed - a field initialiser is an injection context too. After a call to
closeChronicle() the chronicler falls silent for good: I checked it on a running effect, and after destroy() no further change to the signal calls the body any more, while the registered cleanup runs one last time at the moment of destruction. Absolutely nothing changes apart from the chronicler: the patrols signal can still be written to and read from, the template still shows the current number, and other effects watching the same tower carry on undisturbed. One book was closed, not the tower.An effect takes a second, optional argument as well: an options object. In code written a few years ago you will most often find one entry in it,
allowSignalWrites, and it is better to know what it means before you copy it into a project of your own.1effect(() => {
2 // the effect body goes here
3}, { allowSignalWrites: true });The story is short. In Angular 16, 17 and 18, writing to a signal from inside an effect was forbidden by default and ended with an error; this option was a gate you had to open deliberately. From Angular 19, the version you are working on, the write is always allowed, and the option itself has been marked as deprecated with a note saying outright that it is no longer required. I checked the type definitions in Angular 19: the field is still there, so the code will compile, but it does nothing. Do not add it to new code, @name, and when you come across it in somebody else's project, treat it as a warning sign: somebody there was writing to signals from an effect, and that usually means what they really wanted was a derived value.
Now that you know what an effect is not, let us look at a task cut exactly to its shape - and it happens to be the one that makes it easiest to understand why effects exist at all. A student chooses an interface theme, light or dark. The choice is meant to survive the tab being closed, so it has to be put away in the browser's memory. The tool for that is
localStorage with its setItem method, which takes two strings: the key you are stowing something under, and the value. This is code from outside Angular, browser code and utterly ordinary - and that is exactly why it is a job for an effect and not for a derived value.1import { Component, signal, effect } from '@angular/core';
2
3@Component({
4 selector: 'app-theme',
5 standalone: true,
6 template: '<button (click)="toggleTheme()">Change theme</button>'
7})
8export class ThemeComponent {
9 theme = signal('light');
10
11 constructor() {
12 effect(() => {
13 localStorage.setItem('theme', this.theme());
14 });
15 }
16
17 toggleTheme(): void {
18 this.theme.update(t => t === 'light' ? 'dark' : 'light');
19 }
20}I ran this component and the behaviour is exactly what you would expect. Right after the first render the string "light" is already sitting in the browser's memory, because the effect ran once on its own. After the first click it is "dark", after the second it is "light" again - the
toggleTheme() method flips the value of the signal, and the chronicler stows it away in the browser's storage without being asked. Once again, notice what stayed unchanged: theme is an ordinary writable signal, toggleTheme() uses update() from the previous lesson and does not contain a single word about browser memory, and the template reads the theme just like any other value. If tomorrow you decided to save it on a server instead of in the browser, you would change one line inside the effect and nothing else.What else is an effect good for? For everything that is an action rather than a value and is supposed to happen after a change of state. For keeping a journal and logging changes, like our chronicler. For stowing state away in the browser's memory, as we have just done. For calling an external API, when a change has to reach beyond the application. And for working with libraries that know nothing about Angular - a map, a chart, a text editor - because a library like that simply has to be called and told that the data has changed.
A handful of descriptions circulate around effects that sound plausible and are untrue. Now that you know the mechanics, let us knock them down one at a time, because a mistake here results in code that behaves strangely for no visible reason.
An effect does not create animations. Angular has a separate package for animating, and the simplest transitions are done in CSS anyway. An effect knows nothing about the template, nothing about the elements on the page, and cannot move a thing - it calls only the function you handed it yourself. Yes, you could call a foreign animation library inside that function, but then it is the library doing the animating and the effect is merely the messenger reporting that something has changed.
An effect does not combine several signals. We talked about this over the scribe: combining means computing one value out of several others, and values are what
computed() returns. The function of an effect has no way of returning a result, because nobody is there to receive it.An effect does not optimise rendering. Performance in this module comes from something else entirely: from the fact that a signal reports its own change, so Angular refreshes only those fragments of the view that read it, instead of walking round every castle in turn. An effect speeds up none of that - on the contrary, every effect you add is one more function to run after a change. It is a tool for doing things, not for making them faster.
An effect does not render lists or anything else on the screen. Display is the template's business, and for walking through a collection you have the
@for loop with its compulsory track, which you know from the module about components. An effect has no access to the view and cannot add a single character to the page.And the last one, the most important in large applications: an effect is not the place for complex business logic. It is tempting, because it runs by itself, but it has three properties that become expensive in serious code. It returns no result, so you cannot call it in a test and check what it computed. It runs at a moment chosen by Angular rather than by you, so the order of events slips out of your control. And it is tied to the life of the component, so logic locked inside it disappears together with the screen. Domain rules and shared operations live in a service, that is, in the guild from the earlier module - leave the effect for reports sent to the outside world.
Remember, @name: the scribe computes and posts the result on the board, while the chronicler computes nothing - he walks out through the gate and writes in the book that the smoke has changed.