Star

Angular untracked(): Read Signals Without Triggering Effects or Computed

Learn how Angular's untracked() function lets you read signal values inside effects and computed without registering a reactive dependency. Real-world examples included.

RJ
RAMA JONNADA
Senior Frontend Developer with 4+ years of experience in Angular and modern web technologies.

TL;DR: untracked() lets you read a signal inside an effect() or computed() without making that signal a reactive dependency. The computation or effect will not re-run when that signal changes. Use it when you need to sample a signal's current value without subscribing to its future changes.

Angular's untracked() function is one of the smallest APIs in the signals system and one of the most misunderstood. Most Angular developers never need it until they hit a specific edge case — and then they search for it without knowing what it is called. This guide explains exactly what untracked does, when to reach for it, and three real patterns where it prevents bugs.


How Angular Signals Track Dependencies

Before untracked makes sense, you need a clear mental model of how Angular tracks reactive dependencies. Any signal read that happens inside a reactive context — an effect() callback, a computed() callback, or a template — is automatically registered as a dependency. When the signal updates, the reactive context re-runs.

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

const count = signal(0);
const doubled = computed(() => count() * 2); // count is a dependency of doubled
// When count changes → doubled re-evaluates

This is the feature, not a bug. But sometimes you want to read a signal's value without becoming a subscriber to it. That is exactly what untracked is for.


What untracked() Does

untracked() accepts a function, runs it outside any reactive tracking context, and returns its result. Any signal reads that happen inside that function are invisible to the surrounding effect() or computed().

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

const source = signal('hello');
const extra = signal('world');

effect(() => {
  // source IS tracked — this effect re-runs when source changes
  const s = source();

  // extra is NOT tracked — this effect does NOT re-run when extra changes
  const e = untracked(() => extra());

  console.log(s, e);
});

The effect above runs whenever source changes. When extra changes, nothing happens — the signal was read, but its subscription was never registered.

You can also use untracked directly in computed():

const result = computed(() => {
  const primary = primaryValue();       // tracked
  const snapshot = untracked(() => configSignal()); // not tracked
  return `${primary} (config: ${snapshot})`;
});

result re-evaluates when primaryValue changes, but not when configSignal changes. It uses whatever value configSignal happened to have at the last re-evaluation.


Problem: Effect Re-Running Too Often

The most common reason to reach for untracked is an effect() that has too many dependencies and re-runs on changes that shouldn't matter.

Consider a logging effect that records user activity. It needs to know the current user ID to attach to logs, but it should only fire when an action occurs — not every time the user profile updates.

import { Component, ChangeDetectionStrategy, signal, effect, inject } from '@angular/core';
import { AuthService } from './auth.service';

@Component({
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'app-tracker',
  template: `<button (click)="logAction('click')">Track click</button>`,
})
export class TrackerComponent {
  private readonly auth = inject(AuthService);
  private readonly lastAction = signal<string | null>(null);

  constructor() {
    effect(() => {
      const action = this.lastAction();
      if (!action) return;

      // We need the userId for the log entry, but we do NOT want the
      // effect to re-run every time the user profile refreshes.
      const userId = untracked(() => this.auth.currentUser().id);

      console.log(`[audit] user ${userId} performed: ${action}`);
    });
  }

  logAction(name: string): void {
    this.lastAction.set(name);
  }
}

Without untracked, this effect would re-log the last action every time this.auth.currentUser() changes — for example, when a profile photo updates. That is a bug: stale data re-emitting as new events. untracked samples the user ID at the moment the effect runs without subscribing to future user changes.


Problem: Avoiding Circular Dependencies in computed()

untracked is also useful when a computed() needs a signal as a static configuration value that should not invalidate the computation.

import { computed, signal, untracked } from '@angular/core';

const pageSize = signal(20);      // changes when user picks page size
const currentPage = signal(1);    // changes on navigation
const total = signal(500);        // changes when data loads

// We want this computed to re-run only when currentPage or total changes.
// pageSize is "configuration" — we just need its current value.
const paginationLabel = computed(() => {
  const page = currentPage();
  const totalItems = total();
  const size = untracked(() => pageSize()); // sample, don't subscribe

  const start = (page - 1) * size + 1;
  const end = Math.min(page * size, totalItems);
  return `Showing ${start}–${end} of ${totalItems}`;
});

Here pageSize changes rarely and independently. If it were tracked, every page size change would invalidate paginationLabel even without a page navigation. With untracked, the label re-evaluates on navigation — and picks up the current page size at that moment anyway, because it reads it then.


Problem: One-Time Initialization Inside effect()

A common pattern is writing an effect() that sets up something once based on a signal's initial value, then should never tear down and redo that setup.

import { Component, ChangeDetectionStrategy, signal, effect, ElementRef, inject, untracked } from '@angular/core';

@Component({
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'app-chart',
  template: `<canvas #canvas></canvas>`,
})
export class ChartComponent {
  private readonly el = inject(ElementRef);
  readonly theme = signal<'light' | 'dark'>('light');
  readonly data = signal<number[]>([]);

  constructor() {
    effect(() => {
      // Re-run when data changes — that's the trigger
      const points = this.data();

      // Read theme as a snapshot — we don't want a theme change alone
      // to tear down and rebuild the chart
      const currentTheme = untracked(() => this.theme());

      this.renderChart(points, currentTheme);
    });
  }

  private renderChart(points: number[], theme: string): void {
    // chart rendering logic
  }
}

Using ZyraUI with Signal-Driven State

Patterns like the ones above — selectively reactive computations, audit-safe logging effects — appear constantly in real applications. When you are building the UI layer on top of signal state, ZyraUI components integrate cleanly because they accept plain @Input bindings that receive signal-derived values.

Here is a data table that re-renders only when the page changes, using untracked to avoid re-rendering on filter changes that don't affect the visible page:

import { Component, ChangeDetectionStrategy, signal, computed, untracked, inject } from '@angular/core';
import { ZyraTableComponent, ZyraButtonComponent } from '@zyra-ui/angular';
import { DataService } from './data.service';

@Component({
  selector: 'app-data-view',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ZyraTableComponent, ZyraButtonComponent],
  template: `
    <zyra-table [rows]="visibleRows()" [columns]="columns" />
    <zyra-button (click)="nextPage()">Next</zyra-button>
  `,
})
export class DataViewComponent {
  private readonly dataService = inject(DataService);

  readonly currentPage = signal(1);
  readonly pageSize = signal(20);
  readonly searchFilter = signal('');

  readonly columns = [
    { key: 'name', label: 'Name' },
    { key: 'status', label: 'Status' },
  ];

  readonly visibleRows = computed(() => {
    const page = this.currentPage();
    // page drives this computed; searchFilter is sampled, not subscribed
    const filter = untracked(() => this.searchFilter());
    const size = untracked(() => this.pageSize());
    return this.dataService.getPage(page, size, filter);
  });

  nextPage(): void {
    this.currentPage.update(p => p + 1);
  }
}

The table re-renders when the user navigates pages. Typing in the search filter does not trigger a re-render mid-keystroke — only a deliberate page navigation does. Explore all 60+ components at zyraui.dev, including tables, buttons, inputs, and more.


When NOT to Use untracked()

untracked is not a performance shortcut to use freely. Overusing it defeats the purpose of reactive signals — you lose automatic UI consistency. Only reach for it when:

  • You need a snapshot of a signal that should not be a trigger for re-running
  • You have diagnosed that a specific dependency is causing unwanted re-runs
  • You are doing one-time setup where only the first value matters

If you find yourself wrapping every signal read in untracked, the real fix is probably restructuring your state or splitting one large effect into two smaller ones. The Angular signals guide on angular.dev covers the reactive model in depth and is the best reference for deciding when opting out of tracking is appropriate.

See also the ZyraUI docs for component-level examples of signals used cleanly without over-suppressing reactivity.


Wrapping Up

untracked() is a surgical tool. It lets you read a signal's current value without subscribing to its future changes — which matters when you want an effect to fire on one trigger but still have access to another signal's current value at runtime. Use it sparingly, only after verifying that a dependency is causing genuine problems, and your signal graphs will stay clean and predictable.


Frequently asked questions

What does Angular untracked() do?

untracked() executes a function outside Angular's reactive tracking context. Any signals read inside the function are not registered as dependencies of the surrounding effect() or computed(). The reactive context will not re-run when those signals change — it only re-runs when its tracked dependencies (reads outside untracked) change.

When should I use untracked() in Angular?

Use untracked() when you need the current value of a signal inside an effect() or computed() but you do not want that signal to be a trigger for re-running. Common cases include audit logging (where the user profile should not re-trigger a log entry), pagination labels (where page size is configuration, not a navigation event), and one-time chart or map initialization.

Does untracked() work inside Angular templates?

No. Templates are always reactive — signal reads in templates automatically register as dependencies so the view updates correctly. untracked() is intended for use inside effect() and computed() callbacks in TypeScript. Suppressing reactivity in a template would break the component's change detection contract.

Is untracked() the same as calling a signal outside a reactive context?

Functionally similar but not the same. Reading a signal outside a reactive context (e.g., in ngOnInit or in a plain method) also does not register a dependency — but that is because there is no reactive context to register with. untracked() actively suppresses tracking inside an existing reactive context, which is a meaningfully different operation.

Topics
angularsignalsuntrackedangular effectsangular 22