skip to content

In Angular, what does untracked() change about signal reads inside a reactive function such as computed(), and when should you use it?

level: middleimportance: should knowfreq 40%

answer

  1. read without subscribing
  2. clears the active consumer
  3. takes a function, returns its result
  4. snapshot that can go stale

basics

~20 s

untracked(fn) runs fn with dependency tracking switched off and returns its result, so signals read inside it do not become dependencies. Use it for values you deliberately want to sample, never to hide a real input.

solid answer

~40 s

`untracked()` from `@angular/core` executes a function in a non-tracking context: while it runs there is no active consumer, so signal reads inside it are not recorded, and it returns whatever the function returns. Since a signal is itself a zero-argument function, `untracked(rate)` reads one signal without tracking. Inside a `computed()` that means the computed will not recompute when the untracked signal changes; it keeps its cached value until a tracked dependency changes, so the untracked value can be stale. That is right only when you really want a snapshot. It is more common in effects, to stop incidental reads (a counter in a log line, a service that reads signals internally) from becoming triggers. It also lets a write run without `NG0600`, which is an escape hatch, not a pattern.

code

ts · 26 lines
ts
import {Component, computed, effect, signal, untracked} from '@angular/core';

@Component({
  selector: 'app-cart-total',
  template: `<p>{{ totalLabel() }}</p>`,
})
export class CartTotal {
  readonly subtotal = signal(0);
  readonly locale = signal('en-GB');
  readonly edits = signal(0);

  // Recomputes when subtotal changes; locale is sampled and can go stale.
  readonly totalLabel = computed(() => {
    const total = this.subtotal();
    const locale = untracked(this.locale);
    return new Intl.NumberFormat(locale, {style: 'currency', currency: 'EUR'}).format(total);
  });

  constructor() {
    // Re-runs when subtotal changes; edits is read for the log line only.
    effect(() => {
      const total = this.subtotal();
      console.log(`subtotal ${total} after ${untracked(this.edits)} edits`);
    });
  }
}

go deeper

for a junior

Recall that untracked() lets you read a signal inside computed() or effect() without that signal becoming a trigger.

for a middle

Explain that untracked() clears the active consumer, returns the callback's value, and leaves a computed with a snapshot that goes stale until a tracked input changes.

for a senior

Show when an untracked read is a deliberate policy versus a hidden dependency bug, and why using it to silence NG0600 usually signals a design problem.

for a principal

Set review guidance for untracked(): where sampling is an accepted policy, where it must be justified, and how to keep stale-state bugs from becoming normal.

## What tracking normally does In Angular, reading a signal inside a **reactive context** records a dependency. The reactive contexts are templates, `computed()` derivations and effects. Whatever signals the function reads while it runs become its dependencies, and a change in any of them makes it run again (a computed lazily on its next read, an effect on its next scheduled run). Usually that is exactly what you want. Sometimes a read is **incidental**: you need the value right now, but a later change to it should not cause a re-run. ## What `untracked()` does ```ts import {untracked} from '@angular/core'; const result = untracked(() => someSignal() + otherSignal()); const snapshot = untracked(rate); // a signal is a () => T, so this works too ``` - It saves the current active consumer, sets it to none, runs your function, and restores the previous consumer afterwards, even if the function throws. - Signal reads during that time are **not recorded** as dependencies of the surrounding computed, effect or template. - It **returns** your function's return value. - It only affects **synchronous** code inside the callback. Reads after an `await` are not tracked anyway, because the reactive context does not survive an asynchronous boundary. ## Inside `computed()`: a deliberate snapshot ```ts readonly totalLabel = computed(() => { const total = this.subtotal(); const locale = untracked(this.locale); return new Intl.NumberFormat(locale, {style: 'currency', currency: 'EUR'}).format(total); }); ``` Here `totalLabel` recomputes when `subtotal` changes but **not** when `locale` changes. Because a computed is memoized, a locale switch leaves the old label on screen until the next cart change. That is either a bug or a deliberate policy, and you should be able to say which. The consequences to state in an interview: 1. The computed's value is **only as fresh as its tracked inputs**. 2. An untracked read gives the value **at the time of the last recomputation**, not the current one. 3. If the untracked value should ever update what the user sees, it should be tracked. In practice, untracked reads inside a `computed()` are rare. Most derivations should track every input. ## Where it is common | Context | Typical untracked use | |---|---| | effect | reading a counter or setting just to include it in a log or analytics call | | effect | calling a service or third-party code that may read signals internally, so its reads don't become triggers | | computed | sampling a configuration value that should not trigger recomputation, knowing it can go stale | | any | reading the current value in a helper that must behave the same inside and outside reactive code | The Angular guide's two canonical examples are both effects: logging `counter` without re-running when it changes, and wrapping a logging service call so its internal reads are not counted. ## Writes inside `untracked()` Writing to a signal while a `computed()` is computing, or while a template is being evaluated, throws `NG0600`. A write wrapped in `untracked()` does not throw, because there is no active consumer to forbid it. The error guide allows this only when the write is intentional and cannot be moved. Treat it as a smell: a computed or template that writes state is usually a derivation that should be its own `computed()`, or a side effect that belongs in an event handler or effect. ## Mistakes to avoid - **Hiding a real dependency** to "fix" a loop or a performance issue. The result is stale state that is hard to reproduce. - Believing `untracked()` makes the read **asynchronous** or **lazy**; it runs the function immediately. - Looking for a `peek()` method on the signal. Angular's API is the free function `untracked()`. - Wrapping the whole body of a `computed()` in `untracked()`; the computed then has no dependencies and never recomputes after its first read.

  • In Angular, what happens to a computed() whose entire body is wrapped in untracked()?
    It has no dependencies. It runs on its first read, caches that result, and never recomputes, because no signal was recorded during the run. It behaves like a constant captured at first read, which is almost never what was intended.
  • Why does wrapping a write in untracked() avoid NG0600, and should you rely on it?
    The write check only forbids writes while a computed or template is the active consumer; `untracked()` clears the active consumer, so the check passes. Angular's error guide allows it only for intentional writes that cannot be moved. Usually the right fix is a separate `computed()` or moving the write to an event handler.

saying these in an interview costs you the question

  • untracked() defers the read until after change detection finishes.
  • Angular signals have a peek() method for reading without tracking.
  • An untracked read inside computed() still triggers a recompute, just later.
  • untracked() is the standard fix for a computed that recomputes too often.
  • Wrapping a write in untracked() is the normal way to set state in a computed.