linkedSignal: a derived value you can also write to

One reload and two linkedSignal computations side by side: on the left previous.value is returned unchecked, so the picked r-103 is still selected against a list of r-201 and r-202 that no longer contains it; on the right a rows.some membership check drops it and the selection is r-201
COMPANION REPO · LIVE FROM GITHUB

The shape is a list and a selected row. The selection is derived from the list, because when the list arrives you want the first row selected, and when the list reloads you usually want the selection to make sense in the new data. The selection is also written by the user, because clicking a row is the entire point of the screen. So it is derived and writable at the same time, and computed covers exactly half of that: it gives you the derivation and then hands back a read-only signal.

What people do next is copy the value into a plain signal and add an effect to push the derivation back in:

readonly rows = signal<readonly Row[]>(FIRST_LOAD);
readonly selected = signal<string | null>(null);

constructor() {
  effect(() => {
    const rows = this.rows();
    this.selected.set(rows[0]?.id ?? null);
  });
}

That is where "the selection survived the reload" bugs come from, though not quite for the reason people assume. I will come back to that, because the effect version is better than its reputation and the honest comparison is narrower than you would guess.

Angular has a third thing. linkedSignal is a writable signal whose value is produced by a computation, and whose computation takes the value back when its inputs change:

readonly rows = signal<readonly Row[]>(FIRST_LOAD);
readonly selected = linkedSignal<string | null>(() => this.rows()[0]?.id ?? null);

selected.set('r-103') is legal. So is reading it in a template. It shipped in Angular 19 as developer preview and became public API in v20; the v21 type definitions I built against carry @publicApi 20.0 with no experimental or preview marker on either overload, and v22's say the same thing.

A WRITE ON TOP OF A DERIVED VALUE · TWICE
 
ROWS()
selected() → r-101
 

The reset rule

The part worth getting exactly right is when your write survives and when it does not, because "when its inputs change" hides two details that bite.

A write stands as long as nothing the computation reads has changed. Unrelated signals do not touch it. Neither does setting the source to a value it already holds, since set compares with Object.is by default and an identical reference is not a change at all:

selected.set('r-103');

unrelated.set('sort:amount');   // still 'r-103'
rows.set(FIRST_LOAD);           // same array reference, still 'r-103'

The flip side of that comparison is that a reload which produces a brand-new array with byte-identical contents is a change, and does drop your write. If your list comes back from HTTP, every reload is a new array. That is the behaviour you want here and it is worth knowing it is reference-based rather than deep.

When the source does change, the write is gone and the value is whatever the computation returns:

selected.set('r-103');
rows.set(RELOAD);

selected();   // the computation's answer, on this read

No tick, no flush, no await on that last line. The reset is pulled rather than pushed: the getter brings the node up to date before it returns, so the first read after the change is already correct. That is the mechanism the rest of this post rests on, and it is the one real difference from the hand-written version.

The second detail is the one that surprised me. In the form that takes a source and a computation (one options object, not two arguments) the computation is reactive too, not just the source, so a signal you read inside the computation can drop your write even though the source never moved:

const selected = linkedSignal<readonly Row[], string | null>({
  source: () => rows(),
  computation: (rows) =>
    fallbackToLast() ? (rows.at(-1)?.id ?? null) : (rows[0]?.id ?? null),
});

selected.set('r-102');
fallbackToLast.set(true);   // rows never changed
selected();                 // 'r-103'

The framework documents this, and I still had to prove it to myself before I believed the write was gone. If you want something in there that must not trigger a reset, untracked(fallbackToLast) reads it without subscribing, the same as in a computed; the demo asserts that the write survives that version and that the source still resets it.

Keeping the previous choice

Falling back to the first row every time is fine for a demo and wrong for real screens. What you usually want is: keep the user's pick if the new data still contains it, otherwise start over. The source-and-computation form exists for this. Its computation gets a previous object holding the old source and the signal's current value, local write included:

readonly selected = linkedSignal<readonly Row[], string | null>({
  source: () => this.rows(),
  computation: (rows, previous) => {
    const stillThere =
      previous?.value != null && rows.some((row) => row.id === previous.value);
    return stillThere ? previous.value : (rows[0]?.id ?? null);
  },
});

Both directions are asserted in the demo. Pick r-103, reload into a list that still has r-103, and the selection is r-103. Reload into one that does not, and it is the first row of the new list. previous is undefined on the very first run and populated from then on, which is why the null check is there and not decoration.

The rows.some(...) line is not decoration either, and it is the one place this form will bite you. Write the computation as previous?.value ?? rows[0]?.id ?? null, which reads perfectly sensibly, and you have built the original bug back with better tooling: it compiles, it type-checks, and it holds r-103 selected against a list that has no r-103 in it. (The trailing ?? null is not decoration either. Drop it and the demo's tsconfig still accepts the line, but a project that adds noUncheckedIndexedAccess on top of --strict will not, because indexing the array can yield undefined.) The demo asserts that too, because I wanted the failing version on record next to the working one.

One annoyance to budget for: if you leave the generics off this form, it still compiles under --strict and still behaves correctly, but the value type falls out as unknown. The compiler's own declarations for the two versions side by side, from the demo:

export declare const inferred: import("@angular/core").WritableSignal<unknown>;
export declare const explicit: import("@angular/core").WritableSignal<string | null>;

Nothing warns you. previous is what the inference would have had to anchor on, so writing linkedSignal<readonly Row[], string | null>({ ... }) in full is not optional here the way it is in most Angular code.

Being fair to the hand-written version

I want to be careful here, because the easy version of this argument is wrong. A signal plus a reset effect converges on the same value. It is not racy and it is not broken. And when I measured what the template actually paints, the stale value never reached the DOM: the effect runs as part of change detection, before the view refreshes. The full render history for both versions was ['r-101', 'r-103', 'r-102'] — the first row, then the user's pick, then the first row of the new data — and no stale r-103 frame appeared after the reload in either one. If your only reader is the template, in the arrangement I tested (the effect declared in the component that renders the value, driven by fixture.detectChanges()), you will not see a wrong frame.

The difference is when the value is right, and it matters to every reader that is not a template:

component.selected.set('r-103');
component.rows.set(RELOAD);

component.selected();   // linkedSignal: 'r-102'
                        // signal + effect: 'r-103', until the next flush

A click handler that fires in that window builds /api/invoices/r-103 against a list that starts at r-102. So does a guard, an analytics call, or anything reading the selection to decide what to fetch next. There is a matching gap at startup: the linkedSignal has its value at construction, and the effect version is null until the first flush.

Then there is what it costs to write the keep-the-pick version by hand. The effect now has to read the signal it also writes, and reading it the obvious way makes every user pick re-enter the reset:

effect(() => {
  const rows = this.rows();
  const pick = this.selected();     // tracked, so this effect now depends on
                                    // the signal it is about to write
  const stillThere = pick != null && rows.some((row) => row.id === pick);
  this.selected.set(stillThere ? pick : (rows[0]?.id ?? null));
});

Counting runs in the demo: one run at startup, then two after the first pick, then three after the second, with rows never changing. The demo seeds the signal with the value the computation would produce anyway, so that first run writes nothing new; start it at null as the earlier snippet does and every count goes up by one, because the effect's own first write dirties a signal the effect reads and Angular re-runs it inside the same flush. It terminates, but only because the second pass computes the value that is already there and writing an identical value notifies nobody. Wrap the read in untracked and the count stays at one. That line is the whole difference between a version that behaves and a version that quietly does twice the work, and it is the kind of thing that is obvious in a five-line example and invisible in a real component.

I would rather write previous.value and not have to remember it.

When the plain signal is still right

If the value is not derived from anything (a form field, a filter the user types, an open/closed flag), use signal. If it is derived and genuinely read-only, computed says so in the type and that is worth having; reaching for linkedSignal because it is newer costs you a compiler-enforced guarantee. effect is still the right tool for pushing state somewhere outside the signal graph, which is what it is for.

What is left in between is the case this post is about: a value your code derives, the user then overrides, and something has to derive again once the data underneath moves. Selected rows, a chosen tab, the page number of a table whose filters just changed, a quantity that defaults from stock. It is a specific shape, and once you have the name for it you start seeing it on most screens that load data.

The demo is at github.com/gdhami-net/linked-signal-demo. npm run check runs the twenty-two tests behind every behavioural claim above (the version history comes from the changelogs and the published declarations, cited separately) — no timers anywhere, TestBed.tick() is the flush, so the before-and-after states are chosen rather than raced. npm run types emits the two declarations quoted above. Angular 21.2.22 and TypeScript 5.9.3 on Node 26.1.0; I have not repeated any of it on Linux or in a real browser.