Skip to main content

5 min read #angular #signals #linkedsignal #state #frontend

The dropdown that wouldn't reset — a linkedSignal story

Every list-with-selection UI has the same bug: the list changes, the selection points at something that's gone. linkedSignal is the primitive that finally fits this shape — derived state the user can still override.

A search page. A list of results, one of them selected. The user types, the results change — and the selection now points at an item that no longer exists. Every Angular developer has shipped this bug at least once, because for years the framework gave us no primitive that fit the shape of the problem.

Think about what the selection actually is. It's derived from the results — when they change, it should reset. But it's also writable — the user clicks to change it. computed() gives you derived but read-only. signal() gives you writable but disconnected. The selection is both, and both tools are wrong alone.

The two workarounds we all wrote

Workaround one: a writable signal plus an effect that resets it.

selected = signal<Result | null>(null);

constructor() {
  effect(() => {
    this.results();              // subscribe to the list
    this.selected.set(null);     // reset on change
  });
}

It works, until it doesn't: the effect runs after change detection, so there's a frame where the template renders a selection pointing into the old list. Workaround two is sprinkling selected.set(null) at every call site that touches the list — which holds up exactly until a new call site forgets.

What linkedSignal actually says

results = signal<Result[]>([]);
selected = linkedSignal(() => this.results()[0] ?? null);

// user clicks → plain write, like any signal
select(r: Result) { this.selected.set(r); }

Read it as a sentence: "selected follows results, unless someone wrote to it more recently — and the moment results changes again, following resumes." Derivation and overridability in one declaration, recomputed synchronously with the source, so the stale-frame window from the effect version doesn't exist.

The previous-value trick

Resetting to the first item is sometimes rude. If the user's selection survived the list update, keep it — and the long form hands you what you need:

selected = linkedSignal<Result[], Result | null>({
  source: this.results,
  computation: (list, previous) => {
    const kept = list.find(r => r.id === previous?.value?.id);
    return kept ?? list[0] ?? null;
  },
});

The computation receives the new source value and the previous state of the linked signal. "Keep the selection if it still exists, otherwise fall back" — the behavior users actually expect from a well-made UI, expressed in four lines, in one place, instead of scattered across resets.

Where it doesn't belong

Two boundaries keep it honest. If the value is purely derived and never user-written, that's computed() — reaching for linkedSignal there just advertises writability you don't want. And if the "source" is asynchronous — the selection should reset when an HTTP call returns — the deriving belongs in resource() land, not here; linkedSignal is synchronous by nature.

Small API, narrow purpose. But the bug it removes is one I'd been finding in code reviews for years, and now the fix is a type signature instead of a convention someone has to remember. That trade is the story of modern Angular in one primitive.