Angular NotesRohit’s interview study guide
Chapter 08

Signals

Angular's reactive primitive — signal, computed, effect, linkedSignal and untracked — and how it fits with RxJS.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Signals vs plain properties

A plain class property is just a slot in memory. When you write this.count = 5, nothing else in the program finds out: JavaScript has no hook that says "this field changed". So for years Angular had to guess when your data might have changed. It used zone.js to notice every click, timer and HTTP response, and then re-checked every binding in the app, just in case (see zone.js and the old model).

A signal is a value wrapped in an object that knows who is reading it. You read it by calling it — count() — and you change it through its API — count.set(5). Because every read and write goes through the signal, Angular can record who read it (a template, a computed, an effect) and, on a write, tell exactly those readers that they are out of date. Nothing has to be guessed.

A good mental model is a spreadsheet. A signal is a cell holding a value. A computed is a cell with a formula (=A1*B1). A component template is a formula too: it reads some cells and displays the result. When you type a new value into A1, the spreadsheet doesn't recalculate every cell in the workbook — it recalculates the ones whose formula references A1. That is the whole idea behind signals, and it's why Angular 22 can run without zone.js (see Zoneless Angular).

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

@Component({
  selector: 'app-plain-default',
  template: `<p>{{ label }}</p>`,
})
export class PlainLabel {
  label = 'start';
  later() {
    setTimeout(() => (this.label = 'changed'));
  }
}

@Component({
  selector: 'app-sig',
  template: `<p>{{ label() }}</p>`,
})
export class SignalLabel {
  label = signal('start');
  later() {
    setTimeout(() => this.label.set('changed'));
  }
}
What’s happening
  1. Both components start by rendering start. In the second one the template calls label(). That call happens while Angular is running the template, inside a reactive context, so the signal records "this component's template depends on me".
  2. later() is called on each, and a setTimeout fires a moment later. In PlainLabel the callback writes this.label = 'changed'. That's an ordinary assignment: the class field changes, but nothing tells Angular. In a zoneless Angular 22 app the DOM keeps showing start. A test confirmed it: componentInstance.label was 'changed' while the <p> still said start.
  3. In SignalLabel the callback calls this.label.set('changed'). The signal compares the new value with the old one (Object.is), sees a difference, and notifies its consumers — here, the template of SignalLabel.
  4. Notifying the template marks that view for refresh and asks Angular's scheduler to run change detection. On that pass Angular re-runs only that template, label() returns 'changed', and the <p> updates.
  5. The difference isn't speed; it's information. A plain property can't announce that it changed; a signal does. In the old world — zone.js plus the old default (now called Eager) strategy — both would have updated, because zone.js re-checked every component after every timer callback. The signal version doesn't need that guesswork.
AdvancedPlain property vs signal reference table
Plain propertySignal
Readthis.countthis.count()
Writethis.count = 1this.count.set(1) / update(fn)
Angular knows it changedNo — it has to re-check to find outYes — the write notifies its readers
Derived valuesgetters or methods that re-run on every checkcomputed(): cached until an input changes
Works without zone.jsOnly if something else triggers a checkYes
AdvancedPitfall: printing the signal function

#Creating and updating signals

signal(initialValue) returns a WritableSignal<T>. The type is inferred from the initial value, so signal(0) is a WritableSignal<number>. There are exactly two ways to change it:

  • set(value) replaces the value.
  • update(fn) computes the new value from the current one: count.update(c => c + 1).

Before notifying anyone, the signal asks "is the new value equal to the old one?" By default it uses Object.is. If they're equal, the write is ignored: no notification, no re-render. That rule is simple and fast, and it has one big consequence that Rohit's notes flag as "Don't mutate state in Angular": a signal only notices a new value, not a changed one. If you push into an array that lives in a signal, the array's contents change but the signal still holds the same array reference, so as far as the signal is concerned nothing happened.

signals.spec.ts
it('set and update', () => {
  const count = signal(0);
  count.set(5);
  count.update((c) => c + 1);
  expect(count()).toBe(6);
});
What’s happening
  1. signal(0) creates a writable signal holding 0.
  2. count.set(5) replaces the value: 0 → 5. Any readers would be told they're stale.
  3. count.update((c) => c + 1) calls the function with the current value 5 and stores the result: 5 → 6. update is just a convenience for set(fn(current)) — and, as you'll see in effect(), its internal read of the current value isn't tracked.
  4. count() reads the value → 6.
AdvancedSpec: array mutation inside a signal

Now the mutation trap, proved with a run counter on a computed:

signals.spec.ts
it('mutating an array inside a signal does not notify', () => {
  const items = signal(['a', 'b']);
  let runs = 0;
  const total = computed(() => {
    runs++;
    return items().length;
  });

  expect(total()).toBe(2); // runs = 1
  items().push('c'); // mutate in place
  expect(items()).toEqual(['a', 'b', 'c']); // the array really changed...
  expect(total()).toBe(2); // ...but computed is stale
  expect(runs).toBe(1);

  items.set(items()); // same reference -> Object.is says "equal" -> ignored
  expect(total()).toBe(2);
  expect(runs).toBe(1);

  items.update((arr) => [...arr, 'd']); // new array -> notifies
  expect(total()).toBe(4);
  expect(runs).toBe(2);
});
What’s happening
  1. items holds an array ['a', 'b']. total is a computed that reads items() and returns its length. Reading total() runs the formula once (runs = 1) and caches 2.
  2. items().push('c') gets the array out of the signal and mutates it. The signal's set was never called, so it never notified total. total still believes its cached 2 is valid.
  3. items() really is ['a', 'b', 'c'] now, but total() returns the stale 2 without even re-running (runs is still 1). In a template, this is the "I pushed an item and the list didn't update" bug.
  4. items.set(items()) looks like a fix but isn't: it sets the same array object. Object.is(old, new) is true, so the write is dropped. Still 2, still one run.
  5. items.update((arr) => [...arr, 'd']) builds a new array containing the old items plus 'd'. A new reference fails the equality check, so the signal notifies; total() re-runs (runs = 2) and returns 4 — counting 'c' too, because the mutation did happen, it just went unnoticed.
  6. The rule: treat values inside signals as immutable. Replace arrays and objects with spread copies ([...arr, x], { ...obj, name }), map, filter — never push, splice or obj.x = ….
AdvancedCustom equal comparator for signals

You can change the equality rule with the equal option. That's useful when a new object can mean "the same thing" — for example two user objects with the same id:

signals.spec.ts
it('custom equal', () => {
  const user = signal({ id: 1, name: 'Ann' }, { equal: (a, b) => a.id === b.id });
  let runs = 0;
  const name = computed(() => {
    runs++;
    return user().name;
  });
  expect(name()).toBe('Ann');
  user.set({ id: 1, name: 'Bob' }); // "equal" by id -> ignored
  expect(name()).toBe('Ann');
  expect(runs).toBe(1);
});
What’s happening
  1. The second argument is CreateSignalOptions: equal (a comparison function) and debugName (a label for Angular DevTools).
  2. name() runs the computed once and caches 'Ann'.
  3. user.set({ id: 1, name: 'Bob' }) passes a brand-new object, but the custom equal only compares ids: 1 === 1, so the signal decides nothing changed. It doesn't store the new object or notify.
  4. name() is still 'Ann' and the computed didn't re-run. That's the double edge of a custom equal: it can save work, but if you compare too little you will drop real updates (here, the name change). Use it when you're sure the ignored fields don't matter to any reader.
AdvancedLimitation: no nested reactivity
Does the template update?
TypeScript
todos = signal([{ title: 'Write notes', done: false }]);

toggle() {
  this.todos()[0].done = true;
}

The template shows @for (t of todos(); track t.title) { {{ t.done }} }. After toggle(), does it show true?

Show answer

No (in a zoneless app with nothing else triggering a check).

What’s happening
  1. this.todos() returns the array; [0] is the object inside it; .done = true mutates that object.
  2. No signal method was called, so todos notified nobody. The component's view isn't marked for refresh and no change detection is scheduled.
  3. The DOM keeps showing false, even though the data says true. If something unrelated later refreshes this component (say another signal its template reads), the new value would suddenly appear — which makes this bug look random.
  4. Fix: this.todos.update(list => list.map((t, i) => i === 0 ? { ...t, done: true } : t)). That creates a new array with a new object, so the signal notifies.

#computed()

computed(fn) creates a derived signal: its value is whatever fn returns, and fn can read other signals. It answers "what is the total price?" in terms of "what is the price and the quantity?" — so you never store the total yourself and never forget to update it.

Three properties make it more than a getter, and all three are checked below:

  • Read-only. A computed returns a Signal<T>, not a WritableSignal<T>. There is no set. Rohit's note — "Computed() is read only" — is enforced by the type system.
  • Memoised. The result is cached. Reading it ten times runs fn once. It only re-runs after one of the signals it read has changed.
  • Lazy and dynamic. It doesn't run until someone reads it, and its dependencies are whatever it actually read on its last run — not every signal mentioned in the code.
computed.spec.ts
it('is memoized and lazy', () => {
  const price = signal(10);
  const qty = signal(2);
  const unrelated = signal('x');
  let runs = 0;
  const total = computed(() => {
    runs++;
    return price() * qty();
  });

  expect(runs).toBe(0); // lazy: not run until read
  expect(total()).toBe(20);
  expect(total()).toBe(20);
  expect(total()).toBe(20);
  expect(runs).toBe(1); // three reads, one run

  price.set(15);
  expect(runs).toBe(1); // still lazy: marked dirty, not recomputed yet
  expect(total()).toBe(30);
  expect(runs).toBe(2);

  price.set(15); // same value -> no notification
  expect(total()).toBe(30);
  expect(runs).toBe(2);

  unrelated.set('y'); // not a dependency
  expect(total()).toBe(30);
  expect(runs).toBe(2);
});
What’s happening
  1. Creating total doesn't run the formula: runs is 0. A computed nobody reads costs nothing.
  2. The first total() runs it. While it runs, Angular records every signal read: price and qty become its dependencies. It caches 20. The next two reads return the cache — runs stays 1.
  3. price.set(15) notifies total, but total doesn't recompute yet; it just marks itself dirty. runs is still 1. This is the push-dirty, pull-value model: writes push a "you're stale" flag, reads pull a fresh value only when needed.
  4. The next read sees the dirty flag, re-runs the formula (runs = 2) and returns 30.
  5. price.set(15) again is dropped by the signal's equality check (15 === 15), so total isn't even marked dirty. unrelated.set('y') notifies nobody relevant: total never read unrelated. Both reads return the cached 30 with no extra run.
  6. This is what Rohit's doc means by "computed() is memoized", and it's why a computed in a template is cheap while a method call recalculates on every check (see Avoid heavy work in templates).
AdvancedDynamic dependencies in computed

Dependencies are recorded fresh on every run, so a branch that wasn't taken isn't a dependency:

computed.spec.ts
it('dependencies are dynamic', () => {
  const showDetails = signal(false);
  const details = signal('v1');
  let runs = 0;
  const view = computed(() => {
    runs++;
    return showDetails() ? `details: ${details()}` : 'summary';
  });
  expect(view()).toBe('summary'); // runs 1, deps: showDetails only
  details.set('v2');
  expect(view()).toBe('summary');
  expect(runs).toBe(1); // details wasn't read last time -> not a dependency
  showDetails.set(true);
  expect(view()).toBe('details: v2'); // runs 2, deps: showDetails + details
  details.set('v3');
  expect(view()).toBe('details: v3');
  expect(runs).toBe(3);
});
What’s happening
  1. First run: showDetails() is false, so the ternary takes the 'summary' branch and never calls details(). The recorded dependencies are just [showDetails].
  2. details.set('v2') has no consumer to notify. view() returns the cached 'summary', runs stays 1. Nothing was wasted on a value the result doesn't depend on.
  3. showDetails.set(true) does notify. The second run takes the other branch, reads details() (now 'v2'), and the dependency list becomes [showDetails, details].
  4. Now details.set('v3') does matter: it marks view dirty and the next read re-runs (runs = 3).
  5. Tracking is about what ran, not what's written in the source. That's also why reading signals inside if, && or early returns can make a computed or template "miss" a change you expected it to react to — it only reacts to what it read last time.
AdvancedUnchanged results stop downstream recomputes

A computed only tells its own readers about a change if its result changed. That cuts off whole chains of work:

computed.spec.ts
it('downstream computed is skipped when upstream value is unchanged', () => {
  const n = signal(2);
  let parityRuns = 0;
  let labelRuns = 0;
  const isEven = computed(() => {
    parityRuns++;
    return n() % 2 === 0;
  });
  const label = computed(() => {
    labelRuns++;
    return isEven() ? 'even' : 'odd';
  });
  expect(label()).toBe('even');
  n.set(4); // isEven recomputes -> still true
  expect(label()).toBe('even');
  expect(parityRuns).toBe(2);
  expect(labelRuns).toBe(1); // label not recomputed: its only input didn't change
});
What’s happening
  1. The graph is n → isEven → label. The first label() runs both formulas once: isEven is true, label is 'even'.
  2. n.set(4) marks isEven dirty and, through it, label as possibly dirty.
  3. Reading label() makes Angular check its dependency first: it re-runs isEven (parityRuns = 2), which returns true again. Computeds use the same equality check as signals, so true === true means isEven's value didn't change.
  4. Since none of label's inputs changed, label doesn't re-run: labelRuns is still 1. In a real app label could be an expensive filter or a whole template — this cut-off is what keeps signal graphs cheap.
AdvancedRead-only and write-in-computed errors

The read-only rule is a compile-time error, and writing to another signal from inside a computed is a runtime error:

readonly-errors.ts
const count = signal(1);
const double = computed(() => count() * 2);
double.set(4); // ❌ TS2339: Property 'set' does not exist on type 'Signal<number>'.

const a = signal(1);
const b = signal(0);
const bad = computed(() => {
  b.set(a() * 2); // throws when bad() is read
  return a();
});
bad(); // NG0600: Writing to signals is not allowed in a `computed`. Signal: 'b'.
What’s happening
  1. computed is declared as returning Signal<T>, which has no set/update. TypeScript rejects double.set(4) with TS2339 — the value can only change by changing count.
  2. bad tries to write b while computing. That's a side effect inside what should be a pure formula, and Angular forbids it at runtime. The real message in Angular 22 is NG0600: Writing to signals is not allowed in a `computed`. Signal: 'b'. Find more at https://v22.angular.dev/errors/NG0600.
  3. Why it's forbidden: a computed may run at any time, any number of times (or not at all, if nobody reads it). If it wrote other state, the app's behaviour would depend on when and how often it happened to be read. Keep computeds pure: read signals, return a value.
How many times does the formula run?
TypeScript
const a = signal(1);
let runs = 0;
const doubled = computed(() => { runs++; return a() * 2; });

a.set(2);
a.set(3);
a.set(3);
doubled(); doubled();
Show answer

runs is 1.

What’s happening
  1. Creating doubled doesn't run it (lazy). The three writes happen before anybody reads it, so there's nothing to notify yet beyond a dirty flag.
  2. a.set(3) the second time is dropped by the equality check.
  3. The first doubled() runs the formula once with a = 3 and caches 6. The second doubled() returns the cache.
  4. Intermediate values (2) are never computed at all — a computed only ever calculates the value someone actually asks for.

#effect() and when not to use it

An effect is code that runs because signals changed, to do something outside the signal world: write to localStorage, log analytics, call an imperative third-party library (a chart, a map), sync the page title. effect(fn) runs fn once, records the signals it read (exactly like a computed), and runs it again whenever one of them changes.

Rohit's doc calls it "the API which people abuse", and the Angular team agrees: an effect is for side effects, not for computing state. If you find yourself writing effect(() => this.b.set(f(this.a()))), you want computed (or linkedSignal). The examples below show the rules that matter in interviews: effects run asynchronously, re-run only for signals they read, need an injection context, and clean up after themselves.

effects.spec.ts
it('re-runs only for signals it READ, not ones it wrote', () => {
  const query = signal('ng');
  const theme = signal('dark');
  const searches = signal(0);
  const log: string[] = [];

  TestBed.runInInjectionContext(() => {
    effect(() => {
      log.push(`search ${query()}`);
      searches.update((n) => n + 1);
    });
  });

  TestBed.tick();
  expect(log).toEqual(['search ng']);

  theme.set('light'); // never read by the effect
  TestBed.tick();
  expect(log).toEqual(['search ng']);

  query.set('angular');
  query.set('signals'); // two writes before the effect runs
  TestBed.tick();
  expect(log).toEqual(['search ng', 'search signals']); // ran once, saw the latest
  expect(searches()).toBe(2);
});
What’s happening
  1. effect() must be created in an injection context; in a test, TestBed.runInInjectionContext provides one. Creating the effect only schedules it. TestBed.tick() runs pending change detection and effects; after it, the effect has run once and logged search ng.
  2. During that run it read query(). It also called searches.update(...), which writes searches — and update's internal read of the old value isn't tracked. So the effect's only dependency is query. This is the point from Rohit's notes: effects re-run when signals they read change, not signals they write.
  3. theme.set('light') changes a signal the effect never read. Nothing happens.
  4. query is set twice before the next flush. The effect is marked dirty on the first write and stays dirty; when it runs, it reads the current value 'signals'. It ran once, not twice, and 'angular' was never logged. Effects are coalesced — they react to state, not to every individual write.
  5. searches() is 2: one increment per run. Writing signals inside effects is allowed by default in Angular 22 (the old allowSignalWrites option is marked deprecated in the typings: "no longer required, signal writes are allowed by default").
AdvancedPitfall: self-triggering effect loops
AdvancedEffects and the injection context

Injection context. "Effect always within constructor…" in Rohit's notes is about this: effect() needs to know which component or service it belongs to, so it can destroy itself when that owner is destroyed. It finds the owner with inject(), which only works while Angular is creating something — a constructor, a field initializer, a factory, or a function passed to runInInjectionContext (see The injection context).

logger.component.ts
@Component({ selector: 'app-logger', template: `{{ count() }}` })
class Logger {
  count = signal(0);
  log: number[] = [];
  constructor() {
    effect(() => this.log.push(this.count())); // ✅ constructor = injection context
  }
}

@Component({ selector: 'app-bad-logger', template: `` })
class BadLogger {
  count = signal(0);
  start() {
    effect(() => console.log(this.count())); // throws NG0203 when start() runs
  }
}

@Component({ selector: 'app-good-logger', template: `` })
class GoodLogger {
  private injector = inject(Injector); // captured during construction
  count = signal(0);
  log: number[] = [];
  start() {
    effect(() => this.log.push(this.count()), { injector: this.injector }); // ✅
  }
}
What’s happening
  1. Logger creates its effect in the constructor. A test that created the component and awaited fixture.whenStable() saw log equal [0]; after count.set(1) and another whenStable(), [0, 1]. Because the effect was created inside a component, it's a component effect: per the effect() docs in core.d.ts, it runs as part of change detection for that component.
  2. BadLogger.start() runs later, from a click or a test, long after construction finished. There is no injection context any more, so effect() can't find its owner. The real error: NG0203: effect() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203.
  3. GoodLogger fixes it by capturing the Injector while it is in an injection context (a field initializer) and passing it in { injector }. A test confirmed the effect then runs and logs [0].
  4. An effect created with a component's injector is destroyed with the component: after fixture.destroy(), setting count and calling TestBed.tick() didn't add to log. No manual unsubscribe, no leak.
  5. effect() also returns an EffectRef with destroy() if you want to stop it early, and { manualCleanup: true } opts out of automatic destruction.
AdvancedCleaning up between runs with onCleanup

Effects can register a cleanup that runs before the next run (and on destroy). That's how you cancel the previous run's work — a timer, a subscription, a request:

effects.spec.ts
it('onCleanup runs before the next run', () => {
  const id = signal(1);
  const log: string[] = [];
  TestBed.runInInjectionContext(() =>
    effect((onCleanup) => {
      const current = id();
      log.push(`start ${current}`);
      onCleanup(() => log.push(`cleanup ${current}`));
    }),
  );
  TestBed.tick();
  id.set(2);
  TestBed.tick();
  expect(log).toEqual(['start 1', 'cleanup 1', 'start 2']);
});
What’s happening
  1. The effect function receives onCleanup, a function you call to register a cleanup callback (typed EffectCleanupRegisterFn in the typings).
  2. First run: logs start 1 and registers a cleanup that remembers current = 1 (a closure).
  3. id.set(2) makes the effect dirty. On the next flush Angular first runs the registered cleanup (cleanup 1), then runs the effect again (start 2).
  4. In real code the cleanup would be clearInterval(timer) or controller.abort(): you start something per value, and the cleanup stops the previous one before the next starts.
AdvancedAbuse case: deriving state in an effect

Now the abuse case. Deriving state through an effect works, but it's late:

effects.spec.ts
it('bad usage: syncing derived state with an effect is stale until it runs', () => {
  const first = signal('Ada');
  const last = signal('Lovelace');
  const fullViaEffect = signal('');
  TestBed.runInInjectionContext(() => effect(() => fullViaEffect.set(`${first()} ${last()}`)));
  const fullViaComputed = computed(() => `${first()} ${last()}`);

  TestBed.tick();
  first.set('Grace');
  expect(fullViaComputed()).toBe('Grace Lovelace'); // correct immediately
  expect(fullViaEffect()).toBe('Ada Lovelace'); // stale until effects flush
  TestBed.tick();
  expect(fullViaEffect()).toBe('Grace Lovelace');
});
What’s happening
  1. Two ways to get a full name: an effect that copies first + last into a third signal, and a computed.
  2. After first.set('Grace'), the computed is right the moment you read it, because reading a dirty computed recomputes it synchronously.
  3. The effect hasn't run yet — effects are scheduled, not synchronous — so fullViaEffect() still says 'Ada Lovelace'. Any code that reads it in between gets a wrong value, and a template could even render the stale name for one pass and then re-render.
  4. Only after TestBed.tick() does the effect catch up. The effect version is also more code, an extra writable signal anyone could set, and an extra render. That's why "use computed for derived state, effect only for side effects" is the rule.
AdvancedGood uses for effect
AdvancedEffect timing: component vs root
Which signals does this effect depend on?
TypeScript
effect(() => {
  if (!this.enabled()) return;
  this.analytics.track(this.page());
  this.lastTracked.set(this.page());
});
Show answer

enabled always; page only when enabled() was true on the last run; never lastTracked. Plus any signal that analytics.track() reads internally.

What’s happening
  1. enabled() is read first on every run, so it's always a dependency.
  2. If enabled() is false the function returns early and never reads page(). On that run page isn't a dependency, so page changes are ignored until enabled flips (dynamic dependencies, exactly like computed).
  3. lastTracked.set(...) is a write, not a read — it doesn't create a dependency.
  4. The hidden one: if track() reads a signal (say a userId() in the analytics service), that read happens during the effect's run and is tracked too. The effect would re-fire on every user change. Wrapping the call in untracked(() => …) prevents that; see untracked.

#Don't expose writable signals

If a service exposes items = signal([]), every component that injects it can call items.set(...). State can then change from anywhere, and when it's wrong you can't tell who did it. Rohit's note says "Exposing writable signal is risky, don't do that". The standard shape is: private writable signal, public read-only view, methods for changes.

asReadonly() returns a Signal<T> view of the same value: readers see every update, but the type has no set or update. computed gives you read-only derived values for free.

cart.service.ts
@Injectable({ providedIn: 'root' })
class CartService {
  private readonly _items = signal<string[]>([]);
  readonly items = this._items.asReadonly();
  readonly count = computed(() => this._items().length);
  add(item: string) {
    this._items.update((list) => [...list, item]);
  }
}

// in a component:
// this.cart.items.set([]);  ❌ TS2339: Property 'set' does not exist on type 'Signal<string[]>'.
What’s happening
  1. _items is private, so only CartService can call set/update on it. TypeScript enforces it, and so do your code reviews: every write goes through add().
  2. items = this._items.asReadonly() is a read-only window onto the same signal. Templates and computeds that read cart.items() are notified of every change, just as if they read _items directly.
  3. count is a computed, so it's read-only by construction and always in sync.
  4. A component trying cart.items.set([]) fails to compile with TS2339 — the type is Signal<string[]>. The service's methods are now its whole API, which also makes it easy to test (see Sharing state with signal-based services).
AdvancedLimitation: deep mutation through asReadonly

#linkedSignal

Some state is derived by default but editable by the user. A size picker defaults to the first available size; the user can pick another; but when the product changes and the list of sizes changes, the selection should reset. computed can't do it — it's read-only. A plain signal can't either — it copies the default once and never resets. Before linkedSignal, people solved this with an effect that wrote the signal, which is exactly the abuse described in effect().

linkedSignal is a writable signal whose value is reset by a computation whenever that computation's inputs change. Think of it as a computed you're allowed to overwrite until its source moves on. The typings mark it @publicApi 20.0.

linked-signal.spec.ts
it('short form: resets to the computation whenever the source changes', () => {
  const options = signal(['S', 'M', 'L']);
  const selected = linkedSignal(() => options()[0]);
  expect(selected()).toBe('S');
  selected.set('L'); // user picks L — it is writable
  expect(selected()).toBe('L');
  options.set(['XS', 'S']); // source changed -> reset
  expect(selected()).toBe('XS');
});
What’s happening
  1. linkedSignal(() => options()[0]) behaves like a computed at first: it reads options (tracked) and its value is 'S'.
  2. Unlike a computed it returns a WritableSignal, so selected.set('L') works and selected() is 'L'. The local override wins…
  3. …until a dependency of the computation changes. options.set(['XS', 'S']) makes the computation run again, and its result 'XS' replaces the user's 'L'.
  4. That's the full contract: source changes reset, local writes override. It's the right fit for "default selection", "current page resets to 1 when the filter changes", "draft text resets when you switch documents".
AdvancedLong form: keeping a still-valid selection

The short form always resets. Often you want something smarter — keep the user's choice if it's still valid. The long form gives the computation the previous source and value:

linked-signal.spec.ts
it('long form: keep the previous selection if it still exists', () => {
  const options = signal(['S', 'M', 'L']);
  const selected = linkedSignal<string[], string>({
    source: options,
    computation: (opts, previous) =>
      previous && opts.includes(previous.value) ? previous.value : opts[0],
  });
  selected.set('M');
  options.set(['M', 'L', 'XL']);
  expect(selected()).toBe('M'); // kept
  options.set(['L', 'XL']);
  expect(selected()).toBe('L'); // M gone -> first
});
What’s happening
  1. source is the signal to watch; computation(source, previous) produces the value. previous is undefined on the first run, and afterwards { source, value } — the previous source and the linked signal's current value, including any local set.
  2. Initially previous is undefined, so the value is opts[0] = 'S'. Then the user sets 'M'.
  3. options changes to ['M', 'L', 'XL']. The computation runs with previous.value = 'M'; 'M' is still in the list, so it returns 'M' — the selection survives.
  4. options changes to ['L', 'XL']. 'M' isn't there any more, so it falls back to 'L'.
  5. The explicit type arguments <string[], string> (source type, value type) help inference when the computation is generic; the typings declare it as linkedSignal<S, D>(options: { source; computation; equal?; debugName?; set? }).
Advancedsignal vs computed vs linkedSignal

The three side by side make the difference concrete:

linked-signal.spec.ts
it('computed vs linkedSignal vs signal', () => {
  const options = signal(['S', 'M']);
  const asSignal = signal(options()[0]); // copies once, never resets
  const asComputed = computed(() => options()[0]); // always derived, not writable
  const asLinked = linkedSignal(() => options()[0]);
  asSignal.set('M');
  asLinked.set('M');
  options.set(['L']);
  expect(asSignal()).toBe('M'); // stale
  expect(asComputed()).toBe('L');
  expect(asLinked()).toBe('L');
});
What’s happening
  1. signal(options()[0]) reads options once, outside any reactive context, and stores 'S'. There's no link: it will never notice options changing.
  2. computed is always options()[0], but you can't set it.
  3. linkedSignal accepts the set('M'), like a signal.
  4. After options.set(['L']): the plain signal still says 'M' (stale — a size that no longer exists), the computed says 'L', and the linked signal reset to 'L'.
AdvancedIntercepting writes with the set option

#untracked

Every signal read inside a computed, effect or template becomes a dependency. Usually that's what you want. Sometimes you need a signal's current value without wanting to re-run when it changes — "log which page the user viewed, and include their name", but re-log only on page changes. untracked(fn) runs fn with tracking switched off and returns its result. Rohit's note: "Untracked is also important".

untracked.spec.ts
it('reads a signal without depending on it', () => {
  const page = signal('home');
  const user = signal('ann');
  const log: string[] = [];
  TestBed.runInInjectionContext(() =>
    effect(() => {
      const p = page(); // tracked
      const u = untracked(user); // not tracked
      log.push(`${u} viewed ${p}`);
    }),
  );
  TestBed.tick();
  user.set('bob');
  TestBed.tick();
  expect(log).toEqual(['ann viewed home']); // user change ignored
  page.set('cart');
  TestBed.tick();
  expect(log).toEqual(['ann viewed home', 'bob viewed cart']); // but latest user is read
});
What’s happening
  1. The effect reads page() normally, so page is a dependency. It reads user through untracked(user) — a signal is a function, so you can pass it directly — so user is not a dependency.
  2. First run logs ann viewed home.
  3. user.set('bob') doesn't re-run the effect: the log is unchanged.
  4. page.set('cart') does. On that run untracked(user) returns the current value, 'bob'. Untracked means "don't subscribe", not "use an old value".
AdvancedShielding an effect from called code

The more important use is protecting an effect from signals read by code it calls, which you may not control:

untracked.spec.ts
it('protects an effect from signals read inside called code', () => {
  const selected = signal(1);
  const cartSize = signal(0);
  const analytics = { track: (id: number) => `item ${id}, cart ${cartSize()}` };
  const tracked: string[] = [];
  const safe: string[] = [];
  TestBed.runInInjectionContext(() => {
    effect(() => tracked.push(analytics.track(selected())));
    effect(() => {
      const id = selected();
      untracked(() => safe.push(analytics.track(id)));
    });
  });
  TestBed.tick();
  cartSize.set(3);
  TestBed.tick();
  expect(tracked).toEqual(['item 1, cart 0', 'item 1, cart 3']); // re-ran because track() read cartSize
  expect(safe).toEqual(['item 1, cart 0']);
});
What’s happening
  1. analytics.track() reads cartSize() internally — the effect's author may not know that.
  2. The first effect calls track() directly, so cartSize is silently tracked. When cartSize changes to 3, that effect fires again and logs a second "item 1" event that nobody asked for.
  3. The second effect reads selected() (the one thing it should react to), then calls track() inside untracked(() => …). Reads inside that callback aren't recorded, so a cartSize change doesn't trigger it.
  4. A good habit: in an effect, read the signals you mean to react to at the top, then do the actual work inside untracked. It makes dependencies explicit and stops accidental re-runs (and accidental loops).

#resource() for async data

Added

computed must be synchronous. But a lot of derived state is asynchronous: "the user whose id is in the route" requires a fetch. resource() is a signal-shaped wrapper around an async load. You give it reactive params and a loader; it calls the loader whenever the params change, cancels the previous call, and exposes the result as signals: value(), status(), error(), isLoading(), hasValue(). In Angular 22 the typings mark resource, ResourceRef and ResourceStatus as @publicApi 22.0.

Think of it as computed for promises: params in, value out, with loading and error states built in.

resource.spec.ts
const userId = signal<number | undefined>(1);
const user = resource({
  params: () => userId(),
  loader: ({ params, abortSignal }) => fakeFetchUser(params, abortSignal),
});

// observed in a test, step by step:
user.status(); // 'loading'   value() undefined
// ...10ms later
user.status(); // 'resolved'  value() { id: 1, name: 'user1' }

userId.set(2); // -> 'loading', value() undefined
userId.set(3); // before 2 finished: request 2 is aborted
// ...later       'resolved', value() { id: 3, name: 'user3' }

user.reload(); // -> 'reloading', value() still { id: 3, ... }

userId.set(-1); // loader rejects
user.status(); // 'error'; error()?.message 'no user -1'; value() throws

userId.set(undefined);
user.status(); // 'idle' — no params, no load

user.set({ id: 9, name: 'local' });
user.status(); // 'local'
What’s happening
  1. params: () => userId() is a reactive function, like a computed. Whenever its result changes, the resource calls loader with { params, abortSignal, previous }. fakeFetchUser resolves after 10 ms (or rejects for negative ids) and listens to abortSignal. Right after creation status() was already 'loading' and value() undefined; after the promise resolved, 'resolved' with the user.
  2. userId.set(2) starts a new load: status goes back to 'loading' and value() becomes undefined — new params mean the old value no longer applies. Setting 3 before 2 finished made Angular abort request 2's AbortSignal (the test recorded abortedWhilePending: [2]). Only user 3 landed: no race where a slow response overwrites a newer one.
  3. reload() re-runs the loader with the same params. Status is 'reloading' and value() keeps the old user, so the UI can show stale data with a spinner instead of blanking out.
  4. When the loader rejects, status() is 'error' and error() holds the error. Reading value() throws: Resource is currently in an error state (see Error.cause for details): no user -1. Guard templates with @if (user.hasValue()) (which was false here) or check status()/error() first.
  5. If params returns undefined, the resource goes 'idle' and doesn't call the loader — the standard way to say "nothing to load yet". And because a ResourceRef is writable, user.set(...) overrides the value locally (status 'local'), handy for optimistic updates.
  6. The calls the loader actually received were [1, 2, 3, 3, -1]: one per params change plus the reload. In a component you create it in a field initializer: like effect, it needs an injection context, and calling it elsewhere fails with NG0203: resource() can only be used within an injection context…. It's destroyed with the component, aborting any in-flight load.
AdvancedTemplate handling for every resource status

The full set of statuses in Angular 22 is the string union 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local'. In a template:

user-card.html
@if (user.hasValue()) {
  <h2>{{ user.value().name }}</h2>
} @else if (user.error()) {
  <p>Couldn't load: {{ user.error()?.message }}</p>
} @else if (user.isLoading()) {
  <p>Loading…</p>
}
What’s happening
  1. hasValue() is true when the resource isn't in the error state and value() isn't undefined (that's its implementation in Angular 22). Inside that branch user.value() is safe to read, and the template type checker narrows it: this template compiled under strict templates and rendered <h2>Ann</h2>. Note that with a defaultValue, hasValue() is already true while the first load is running.
  2. The error branch reads error(), which never throws. Checking it before touching value() avoids the "value() throws in error state" trap.
  3. isLoading() is true for both 'loading' and 'reloading'. Because hasValue() is checked first, a reload keeps showing the old user rather than the spinner.
AdvancedObservable sources with rxResource

If your data source is an Observable, rxResource from @angular/core/rxjs-interop takes a stream function instead of loader:

resource.spec.ts
const id = signal(1);
const r = rxResource({
  params: () => id(),
  stream: ({ params }) => of(`item ${params}`).pipe(delay(5)),
  defaultValue: 'none',
});
r.value(); // 'none' while loading
// ...after the observable emits
r.value(); // 'item 1', r.status() 'resolved'
What’s happening
  1. stream receives the same { params, abortSignal, previous } object and returns an Observable. When params change, the resource unsubscribes from the previous Observable — the same "cancel the old one" behaviour as switchMap (see switchMap).
  2. defaultValue: 'none' is what value() returns while there's no loaded value, so the type is ResourceRef<string> instead of ResourceRef<string | undefined>.
  3. After 5 ms the Observable emits, value() becomes 'item 1' and status 'resolved'. The typings note that if the Observable completes without emitting, Angular throws NG0991 (RESOURCE_COMPLETED_BEFORE_PRODUCING_VALUE).
AdvancedPitfall: using resources for mutations

#Signals and RxJS together

Rohit's notes say "Signal and RxJS complement each other", and that's the right framing. They model different things:

  • A signal is a value that exists now: the current user, the selected tab, the cart total. It always has a value, reading it is synchronous, and it doesn't care how many times it changed in between — only what it is when you look.
  • An Observable is a stream of events over time: keystrokes, WebSocket messages, HTTP responses. It can emit zero, one or many times, can complete or error, and comes with operators for timing (debounceTime), cancellation (switchMap) and combination (combineLatest).

The rule of thumb: RxJS for events and async orchestration, signals for state you render. The interop functions in @angular/core/rxjs-interop convert at the boundary.

interop.spec.ts
it('toSignal reads the latest emission synchronously', () => {
  const subject = new BehaviorSubject(1);
  const value = TestBed.runInInjectionContext(() => toSignal(subject, { requireSync: true }));
  expect(value()).toBe(1);
  subject.next(2);
  expect(value()).toBe(2);
});

it('toSignal without initial value starts undefined', () => {
  const value = TestBed.runInInjectionContext(() => toSignal(of(5).pipe(delay(1))));
  expect(value()).toBeUndefined();
});
What’s happening
  1. toSignal(obs) subscribes to the Observable immediately and stores each emission in a signal. Like effect, it needs an injection context, because it unsubscribes when its owner is destroyed — no takeUntilDestroyed needed.
  2. A BehaviorSubject emits its current value synchronously on subscribe. requireSync: true tells toSignal to rely on that, so the type is Signal<number> (no undefined). If the source doesn't emit synchronously it throws: NG0601: `toSignal()` called with `requireSync` but `Observable` did not emit synchronously.
  3. subject.next(2) pushes a new value; the signal updates synchronously and value() is 2. Any template reading it is notified.
  4. A source that emits later (of(5).pipe(delay(1))) leaves the signal undefined at first, and the type is Signal<number | undefined>. Pass { initialValue: … } to avoid the undefined. This replaces most uses of the async pipe; see The async pipe and toSignal().
AdvancedtoObservable emits settled values only

Going the other way, toObservable(signal) gives you a stream — but a stream of settled values, not of every set:

interop.spec.ts
it('toObservable emits only the settled value', () => {
  const n = signal(0);
  const emitted: number[] = [];
  TestBed.runInInjectionContext(() => toObservable(n)).subscribe((v) => emitted.push(v));
  TestBed.tick();
  n.set(1);
  n.set(2);
  n.set(3);
  TestBed.tick();
  expect(emitted).toEqual([0, 3]);
});
What’s happening
  1. toObservable is implemented with an effect that reads the signal and calls next on a subject. So it emits when the effect runs, not at the moment of each write.
  2. The first TestBed.tick() runs the effect: it emits the initial 0.
  3. Three synchronous writes 1, 2, 3 only mark the effect dirty. On the next flush it reads the current value once and emits 3. 1 and 2 are never emitted.
  4. That's the key difference: a signal is state, so intermediate states that nobody saw are dropped. If you need every event (each keystroke, each message), keep it as an Observable or a Subject — don't route events through a signal.
AdvancedSignals or RxJS: what to reach for
UseReach for
Component state, derived values, what the template showssignal, computed, linkedSignal
Debouncing, throttling, cancelling, retrying, combining streamsRxJS operators
Fetch data based on signal stateresource / httpResource / rxResource
Show an Observable's latest value in a templatetoSignal (or the async pipe)
Feed signal changes into an RxJS pipelinetoObservable

#Sharing state with signal-based services

Added

Put the last few topics together and you have a small, complete state-management pattern that needs no library: a root service holds private writable signals (the state), exposes computed selectors (read-only derived state) and methods (the only way to change state). Any component can inject it; templates read the selectors and refresh automatically. This is the signal version of the classic "service with a BehaviorSubject" (see BehaviorSubject), with less code and no subscriptions.

cart.store.ts
import { Injectable, computed, signal } from '@angular/core';

export interface CartItem {
  id: number;
  name: string;
  price: number;
  qty: number;
}

@Injectable({ providedIn: 'root' })
export class CartStore {
  // state: private and writable
  private readonly _items = signal<CartItem[]>([]);

  // selectors: public and read-only
  readonly items = this._items.asReadonly();
  readonly count = computed(() => this._items().reduce((n, i) => n + i.qty, 0));
  readonly total = computed(() => this._items().reduce((sum, i) => sum + i.price * i.qty, 0));
  readonly isEmpty = computed(() => this._items().length === 0);

  // actions: the only way to change state
  add(item: Omit<CartItem, 'qty'>) {
    this._items.update((items) => {
      const existing = items.find((i) => i.id === item.id);
      return existing
        ? items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i))
        : [...items, { ...item, qty: 1 }];
    });
  }

  remove(id: number) {
    this._items.update((items) => items.filter((i) => i.id !== id));
  }

  clear() {
    this._items.set([]);
  }
}
What’s happening
  1. providedIn: 'root' makes one shared instance for the whole app (see Services with @Service and inject() for the Angular 22 @Service shortcut).
  2. _items is the single source of truth. Nothing outside can write it.
  3. count, total and isEmpty are computeds: memoised, always consistent with _items, and impossible to set by mistake. A component reading total() re-renders only when the total actually changes.
  4. add() never mutates. If the item exists, map returns a new array with a new object for that item (qty + 1); otherwise it spreads the old array and appends. That new reference is what makes the signal notify — see Creating and updating signals.
  5. A test added Book (12) twice and Pen (3) once: count() was 3, total() 27. After remove(1), items() was [{ id: 2, name: 'Pen', price: 3, qty: 1 }].
AdvancedComponents sharing a CartStore
cart.components.ts
@Component({
  selector: 'app-cart-badge',
  template: `<span>{{ cart.count() }} items · {{ cart.total() }}</span>`,
})
class CartBadge {
  protected readonly cart = inject(CartStore);
}

@Component({
  selector: 'app-product',
  template: `<button (click)="cart.add({ id: 1, name: 'Book', price: 12 })">Add</button>`,
})
class Product {
  protected readonly cart = inject(CartStore);
}
What’s happening
  1. CartBadge and Product know nothing about each other — no inputs, outputs or shared parent state. They share the injected CartStore.
  2. In a test that rendered both side by side, the badge first showed 0 items · 0.
  3. Clicking Add twice calls cart.add(...) twice. _items gets a new array each time; count and total are marked dirty; the badge's template read them, so the badge's view is marked for refresh.
  4. After fixture.whenStable() the badge showed 2 items · 24. No subscriptions, no async pipe, no markForCheck — and it works with OnPush (the Angular 22 default) and without zone.js.
AdvancedScaling up with NgRx SignalStore

When does this stop being enough? When the store grows many features and you want conventions: entity collections, devtools, plugins, a standard shape for loading/error state. NgRx SignalStore (@ngrx/signals) formalises exactly this pattern. It isn't installed in the sandbox, so this is illustrative only:

cart.store.ts (NgRx SignalStore, illustrative)
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';

export const CartStore = signalStore(
  { providedIn: 'root' },
  withState({ items: [] as CartItem[] }),
  withComputed(({ items }) => ({
    total: computed(() => items().reduce((s, i) => s + i.price * i.qty, 0)),
  })),
  withMethods((store) => ({
    clear: () => patchState(store, { items: [] }),
  })),
);
What’s happening
  1. withState declares the state; each top-level key becomes a read-only signal on the store (store.items()).
  2. withComputed adds derived signals — the same computed you'd write by hand.
  3. withMethods adds actions; patchState is the only way to write state, and it merges a partial update immutably.
  4. It's the hand-written CartStore above with less boilerplate and an ecosystem around it. For one or two small services, the hand-written version is simpler and has no dependency; reach for SignalStore when many teams or features share state and you want one consistent pattern.
AdvancedState options from local to app-wide

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.