Angular linkedSignal: Writable State That Resets When Its Source Changes [2026]

Link copied
Angular linkedSignal: Writable State That Resets When Its Source Changes [2026]

Angular linkedSignal: Writable State That Resets When Its Source Changes [2026]

Angular Tutorial Module 3: Signals Lesson 3.8

computed() gives you derived state that is read-only. signal() gives you writable state with no dependency on anything else. What about state that needs to be BOTH derived from a source AND independently writable — a form input that defaults to a user's saved preference but the user can override; a selected-tab index that resets when the tab list changes? That's what linkedSignal() is for. Added in v19, it closes the last gap in the signal API.

This is lesson 3.8, the closing lesson of Module 3. After this, every signal primitive Angular ships is in your toolkit.

The problem linkedSignal() solves #

Consider a tab component where the user picks the active tab:

export class TabsComponent {
  tabs = input.required<Tab[]>();
  activeIdx = signal(0);   // user's selection
}

Bug: when the parent passes a new tabs array (e.g., filtered to fewer tabs), activeIdx stays at whatever the user clicked. If they had selected tab 4 and now there are only 3 tabs, the active index is out of range.

Fix attempt with effect():

export class TabsComponent {
  tabs = input.required<Tab[]>();
  activeIdx = signal(0);

  constructor() {
    effect(() => {
      this.tabs();                   // tracked dependency
      this.activeIdx.set(0);          // reset on every change
    }, { allowSignalWrites: true });
  }
}

Works but awkward — requires allowSignalWrites, easy to forget, runs every time tabs changes regardless of whether the index is still valid.

linkedSignal() is the dedicated primitive for this:

import { linkedSignal, input } from '@angular/core';

export class TabsComponent {
  tabs = input.required<Tab[]>();

  // Writable, BUT auto-resets to 0 whenever tabs changes
  activeIdx = linkedSignal(() => 0, { source: this.tabs });

  selectTab(idx: number) { this.activeIdx.set(idx); }   // user can still override
}

When tabs() changes, activeIdx resets to 0. Until then, the user's .set(idx) calls take effect normally. Best of both worlds.

The signature #

The simple form:

function linkedSignal<T>(
  computation: () => T,
  options?: { equal?: ValueEqualityFn<T> }
): WritableSignal<T>

The "source + computation" form (most useful):

function linkedSignal<S, D>(options: {
  source: () => S | Signal<S>;
  computation: (source: S, previous?: { source: S; value: D }) => D;
  equal?: ValueEqualityFn<D>;
}): WritableSignal<D>

Three facts:

  1. The computation runs initially AND whenever source changes — same as computed() for that aspect
  2. The result is a WRITABLE signal — you can .set() and .update() to override
  3. The next source change resets to the computation — overrides are temporary, not sticky

A second pattern: smart reset based on previous state #

The previous argument to computation lets you preserve state across source changes when it still makes sense:

activeTab = linkedSignal({
  source: () => this.tabs(),
  computation: (tabs, previous) => {
    // If the previously-active tab still exists, keep it
    if (previous && tabs.some(t => t.id === previous.value)) {
      return previous.value;
    }
    // Otherwise reset to the first tab
    return tabs[0]?.id;
  },
});

Now the user's selection survives source changes WHEN POSSIBLE. The tab list reordering keeps your selection; only a tab being deleted bumps you to the first one.

This is the pattern that distinguishes linkedSignal() from a naive "reset on source change" — it can intelligently preserve user intent.

When to reach for linkedSignal() #

Four real-world patterns:

1. Form input with a smart default #

userPrefs = inject(UserService).preferences;

// Defaults to the user's saved value, but user can override mid-session
fontSize = linkedSignal(() => this.userPrefs().fontSize);

// Template: <input type="number" [value]="fontSize()" (input)="fontSize.set(+$any($event.target).value)" />

When the user's saved preferences change (logged into a new account, for example), fontSize updates. While they're in the session, they can adjust it freely.

2. Selected item in a dynamic list #

The canonical case shown above — the active index resets when the underlying list changes.

3. Computed default that can be edited #

firstName = signal('Pradeep');
lastName = signal('Bose');

// Default display name, editable
displayName = linkedSignal(() => `${this.firstName()} ${this.lastName()}`);

// Template lets user edit displayName freely. When they edit first/last, displayName resets

Useful for fields like "display name" or "slug" that derive from other fields but the user can manually override.

4. Pagination state #

items = input.required<Item[]>();
pageSize = signal(20);

currentPage = linkedSignal({
  source: () => ({ items: this.items(), pageSize: this.pageSize() }),
  computation: () => 1,    // reset to page 1 when items or pageSize changes
});

When the underlying list or page size changes (filter applied, results re-fetched), pagination resets to page 1.

linkedSignal vs computed vs signal #

Need Primitive
Read-only value derived from signals computed()
Writable value, no dependency on anything signal()
Writable value that DERIVES from a source and RESETS when the source changes linkedSignal()
Two-way binding between parent and child model()
Async-backed value with cancellation resource()

A cheat-sheet question: "can the user override this value?" If yes, NOT computed(). If yes AND it should reset on source change, linkedSignal(). If yes AND it should never auto-reset, plain signal().

Equality control #

Like signal() and computed(), linkedSignal() accepts a custom equality function. Useful when the computation returns a new object reference each time:

config = linkedSignal({
  source: () => this.theme(),
  computation: (theme) => ({ ...defaultConfig, ...theme.overrides }),
  equal: (a, b) => JSON.stringify(a) === JSON.stringify(b),   // value-based equality
});

Otherwise downstream consumers would re-run on every theme change even when the merged config is identical.

Edge cases #

User write before first source emission #

The computation runs eagerly on first read of the linked signal — same as computed(). If you .set() before reading, the computation runs once on next read and your value is preserved until the next source change.

Source that is a computed() chain #

The source option accepts any signal — a writable signal, a computed, an input signal. The computation re-fires whenever any of source's transitive dependencies change:

filteredTabs = computed(() => this.tabs().filter(t => t.visible));
activeIdx = linkedSignal(() => 0, { source: filteredTabs });

When tabs() changes, filteredTabs() re-runs, then activeIdx resets. All transitive.

Order of operations #

If both the source signal AND the linked signal are written in the same synchronous block, the linked-signal write wins (it happens after the source update is observed):

this.tabs.set([newTabs]);          // schedules linkedSignal reset
this.activeIdx.set(2);              // overrides the reset
// activeIdx() is 2, NOT 0

This is intentional — user intent overrides automatic behavior. If you need the reset to win, write to the source AFTER any potential user write in the same tick.

Common gotchas #

Symptom Cause Fix
linkedSignal() doesn't reset when source changes The source isn't actually a signal (passed a raw value) The source option must be a signal or a getter function returning one
Reset happens on every change detection The computation has a non-signal dependency that re-evaluates Make sure the computation only reads tracked signals
Equality doesn't fire Default Object.is is comparing object identity, returns false on new object each time Pass a custom equal: function
Two linked signals fighting Both writing to the same underlying source One should be a computed() (read-only) instead
Confused with model() linkedSignal is for derived state; model() is for parent-child two-way binding Different problems — pick the one that fits the shape

What's next #

This closes Module 3. You now have every signal primitive Angular ships:

  • signal() — writable state
  • computed() — read-only derived state
  • effect() — side effects on signal change
  • model() — two-way binding between parent and child
  • linkedSignal() — writable derived state with auto-reset
  • resource() / httpResource() — async-backed signal-shaped state (lesson 6.3)
  • debounced() / throttled() — signal helpers for rate limiting
  • toSignal() / toObservable() — bridges to RxJS

Module 4 picks up forms — signal forms first (the v22 modern API), then reactive and template-driven for migration cases. Lessons 4.2–4.6 build on the signal primitives.

Try it yourself #

A tab component using linkedSignal() to remember the user's choice when possible:

import { Component, input, linkedSignal, signal } from '@angular/core';

interface Tab { id: string; label: string; }

@Component({
  selector: 'app-tabs',
  template: `
    <nav>
      @for (t of tabs(); track t.id) {
        <button [class.active]="t.id === activeId()" (click)="activeId.set(t.id)">
          {{ t.label }}
        </button>
      }
    </nav>
    <p>Active: {{ activeId() }}</p>
  `,
})
export class TabsComponent {
  tabs = input.required<Tab[]>();

  activeId = linkedSignal<Tab[], string>({
    source: () => this.tabs(),
    computation: (tabs, previous) => {
      if (previous && tabs.some(t => t.id === previous.value)) {
        return previous.value;   // keep user's selection if the tab still exists
      }
      return tabs[0]?.id ?? '';   // fallback to first tab
    },
  });
}

Usage:

@Component({
  template: `
    <app-tabs [tabs]="visibleTabs()" />
    <button (click)="hideOne()">Hide first tab</button>
  `,
})
export class HostComponent {
  allTabs = signal<Tab[]>([{id:'a',label:'A'},{id:'b',label:'B'},{id:'c',label:'C'}]);
  visibleTabs = computed(() => this.allTabs());

  hideOne() { this.allTabs.update(t => t.slice(1)); }
}

Click around the tabs — your selection sticks across re-renders. Click "Hide first tab" while you have a later tab active — the active stays. Click "Hide first tab" while it IS the active tab — the linked signal resets to the new first tab.

YouWhen should I use linkedSignal vs effect() that writes to a regular signal?
Claude · used get_best_practicesUse linkedSignal whenever the dependency is straightforward: “on every change of source X, reset/recompute value Y, but allow user writes in between.” That’s exactly what linkedSignal is built for, and it doesn’t need allowSignalWrites: true. Reach for effect() with manual signal writes when the logic is more complex — multiple sources, conditional resets, side effects beyond just setting the value. The two are tools for the same family of problem; linkedSignal is the cleaner shape when it fits.

Module 3 is complete. Module 4 picks up forms — starting with signal forms, the v22 modern API.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *