In Angular, why might a linkedSignal() reset the user's shipping choice even though the list of options looks unchanged?
answer
- what counts as changed upstream
- new array, same contents
- every read in the shorthand
- computation reads are tracked too
- previous can rescue it
basics
~20 sA linkedSignal recomputes when any signal its function reads reports a change. A source that rebuilds the same list as a new array counts as changed, and so does any extra signal read in the shorthand or computation.
solid answer
~50 sA `linkedSignal()` recomputes whenever a dependency reports a change, and a shorthand computation that returns `options()[0]` then replaces the user's value. Three causes are common. First, the source is a `computed()` or server-backed signal that produces a **new array** with identical contents, for example after a refetch or when an unrelated signal it reads changes; under the default `Object.is` equality that is a change. Second, the shorthand's function is the source, so **every** signal it reads, such as a promo code or the cart, is a trigger. Third, in the object form, signals read inside `computation` are tracked too. Fixes: make the source narrower (the country, not the rebuilt list), read incidental signals with `untracked()`, use the object form's `previous` to keep a still-valid choice, and only if readers care about nothing but ids, give the source `computed()` an id-based `equal`.
code
ts · 32 linesimport {Component, computed, linkedSignal, signal, untracked} from '@angular/core';
interface ShippingOption {
id: string;
label: string;
countries: string[];
}
@Component({
selector: 'app-shipping-select',
template: `<p>{{ selected().label }}</p>`,
})
export class ShippingSelect {
readonly country = signal('DE');
readonly allOptions = signal<ShippingOption[]>([]);
readonly promo = signal<{freeShipping: boolean} | null>(null);
// Rebuilt on every refetch: a new array each time, so the computation below must cope.
readonly options = computed(() =>
this.allOptions().filter(o => o.countries.includes(this.country())),
);
readonly selected = linkedSignal<ShippingOption[], ShippingOption>({
source: this.options,
computation: (opts, previous) => {
// promo influences the default only; untracked so it never triggers a reset.
const preferFree = untracked(this.promo)?.freeShipping ?? false;
const fallback = preferFree ? (opts.find(o => o.id === 'free') ?? opts[0]) : opts[0];
return opts.find(o => o.id === previous?.value.id) ?? fallback;
},
});
}go deeper
Recall that a linkedSignal resets whenever a signal its function reads changes, and that a new array counts as a change.
Explain how dependencies are collected in the shorthand and object forms, and why reference churn in a source triggers resets.
Diagnose unexpected resets with debug names and logging, and fix them by narrowing sources, adding source equality, untracked reads or previous-aware computations.
Decide how the team documents reset rules for user-editable derived state so that dependency changes are deliberate and reviewable.
## The symptom A user picks **Express**. A few seconds later, or after an unrelated action like applying a promo code, the radio button jumps back to **Standard**. The country did not change and the options on screen look identical. ## How a linked signal decides to recompute A `linkedSignal()` behaves like a `computed()` with a writable value on top: 1. When it computes, it records every signal read, in the shorthand's function or in the object form's `source` and `computation`. 2. When it is read, it checks whether any of those dependencies has changed since. 3. A dependency has "changed" when its value moved on under its own equality check: `Object.is` by default, so for objects and arrays that means a **new reference**. 4. If anything changed, the computation runs and its result **replaces** the current value, including one the user set, unless the computation returns something equal under the linked signal's `equal`. So an unexpected reset always traces back to a dependency that reported a change you did not consider meaningful. ## Cause 1: a source that rebuilds the list ```ts readonly options = computed(() => this.allOptions().filter(o => o.countries.includes(this.country())), ); readonly selected = linkedSignal(() => this.options()[0]); ``` `filter()` returns a **new array** each time `options` recomputes. It recomputes whenever `allOptions` or `country` changes, and `allOptions` may be replaced by a refetch that returns the same data as new objects. Each time, `options` reports a change and `selected` resets. ## Cause 2: extra reads in the shorthand ```ts readonly selected = linkedSignal(() => this.promo()?.freeShipping ? this.freeOption() : this.options()[0], ); ``` In the shorthand, the whole function is the source. Applying a promo code, or anything that changes `promo()`, now resets the user's choice. Often the author only wanted a different *default*. ## Cause 3: reads inside the object form's computation Separating `source` from `computation` does not make the computation untracked. The Angular guide says the linked signal updates "when the source changes or when any signal referenced in the computation changes". Reading `this.cart()` inside `computation` to price options means every cart change is a trigger. ## Fixes, in order of preference | Fix | Use when | |---|---| | Link to the narrowest meaningful source, such as `country` instead of a rebuilt list | the reset rule is really "when the country changes" | | Give the source `computed()` an `equal` comparing ids | only ids matter to every reader; otherwise it hides label or price changes | | Read incidental signals with `untracked()` | a value is needed in the computation but should not trigger a reset | | Use the object form and return the previous choice when still valid | refreshes are unavoidable and the choice should survive them | A robust version combines the last two: ```ts readonly selected = linkedSignal<ShippingOption[], ShippingOption>({ source: this.options, computation: (opts, previous) => opts.find(o => o.id === previous?.value.id) ?? opts[0], }); ``` Even if the list is rebuilt, the user's option survives whenever an option with the same id exists. ## Diagnosing it 1. **List the dependencies.** For the shorthand, every signal read in the function; for the object form, the source plus every read in the computation. 2. **Find which one changed.** Give the linked signal and its source a `debugName` and inspect them in Angular DevTools, or log inside the computation to see when it runs. 3. **Check reference churn.** If a source recomputes to a new array or object without meaningful change, that is the trigger. 4. **Decide the rule explicitly.** Write down what should reset the choice and make the dependencies match it. ## Related edge cases - A linked signal's own `equal` option compares its **result**, not its inputs. It can keep the current value when the new result is equal, but it does not stop the computation from running. - A plain `set()` never causes a reset; only dependency changes do. - Resets are lazy: if nothing reads the linked signal, nothing recomputes until the next read.
- In Angular, does the equal option on a linkedSignal() prevent its computation from running after a source change?No. `equal` compares the computation's new result with the current value. The computation still runs; if the result is equal, the linked signal keeps its current value and reports no change. To avoid the recomputation itself, the source must stop reporting a change, for example through an `equal` on the source `computed()`.
- In Angular, why is linking to country() often better than linking to the filtered options list?The country is a primitive that changes only when the user switches country, so it expresses the reset rule directly. A filtered list can be rebuilt as a new array for unrelated reasons, each of which counts as a change under the default reference equality and resets the selection.
saying these in an interview costs you the question
- A linkedSignal only resets when the source's contents differ deeply.
- In the object form, signals read in the computation are never tracked.
- Calling set() on the linked signal can trigger its own reset.
- The linked signal's equal option stops the computation from running.
- Rebuilding an identical array with filter() does not count as a change.