An Angular checkout resets selectedShipping to the first option inside an effect() when the country changes; what goes wrong, and why does linkedSignal() fix it?
answer
- a window with stale state
- the effect runs later than the read
- reading the selection makes it a trigger
- reset on read, in one place
basics
~20 sThe effect resets the selection only when it runs, so reads in between pair the new country with a stale option, and reading the selection inside the effect makes each user choice re-trigger the reset. linkedSignal() recomputes on read, with no stale window.
solid answer
~50 sA reset effect copies state: `effect(() => this.selected.set(this.options()[0]))`. Effects are scheduled, so after `country.set('US')` there is a window where `selected()` still returns a German option; a `computed()` total, a parent view already checked in that pass, or a test reading synchronously sees the inconsistent pair, and the late write can cost another change detection pass. It also runs once at start-up, overwriting any initial value. If you try to keep a still-valid choice by reading `selected()` inside the effect, that read becomes a dependency, so every user selection re-runs the effect, which resets it again unless you remember `untracked()`. `linkedSignal()` removes all of that: the reset is part of reading the value, so any read after the country change gets the recomputed option; the object form's `previous` exposes the current choice without making it a trigger.
code
ts · 28 linesimport {Component, computed, linkedSignal, signal} from '@angular/core';
interface ShippingOption {
id: string;
label: string;
price: number;
}
declare function shippingOptionsFor(country: string): ShippingOption[];
@Component({
selector: 'app-checkout-shipping',
template: `<p>Total: {{ total() }}</p>`,
})
export class CheckoutShipping {
readonly country = signal('DE');
readonly itemsTotal = signal(40);
readonly options = computed(() => shippingOptionsFor(this.country()));
// Before: selected = signal(...) plus effect(() => selected.set(options()[0]))
readonly selected = linkedSignal<ShippingOption[], ShippingOption>({
source: this.options,
computation: (opts, previous) => opts.find(o => o.id === previous?.value.id) ?? opts[0],
});
// Always consistent: reading selected() after a country change recomputes first.
readonly total = computed(() => this.itemsTotal() + this.selected().price);
}go deeper
Recall that a selection which resets when its list changes should be a linkedSignal(), not a signal() reset from an effect().
Explain the stale window an effect leaves, why reading the selection inside the effect makes it a trigger, and how linkedSignal resets on read.
Diagnose intermittent inconsistent totals and reset-on-click bugs caused by reset effects, and refactor them to linkedSignal with a preservation rule.
Set the team rule that effects writing signals from other signals must be justified, and make linkedSignal the default for overridable derived state.
## The code under review ```ts readonly country = signal('DE'); readonly options = computed(() => shippingOptionsFor(this.country())); readonly selected = signal<ShippingOption | undefined>(undefined); readonly total = computed(() => this.itemsTotal() + (this.selected()?.price ?? 0)); constructor() { effect(() => { this.selected.set(this.options()[0]); }); } ``` It works in a demo. In production it produces a cluster of subtle bugs, all from the same cause: **the selection is a copy, and the copy is updated later than the thing it copies**. ## Problem 1: a window of inconsistent state Effects never run at the write. After `country.set('US')`: 1. `options()` immediately returns US options. 2. `selected()` still returns the German option until the effect runs during the next change detection. 3. Anything that reads both in between, such as `total()`, a parent component's template that is checked before the effect runs, or a unit test asserting right after the `set()`, sees a German carrier price against a US order. With a view effect in the same component the component's own template may look right, which is why the bug is intermittent and depends on where readers sit. ## Problem 2: the extra write costs work The effect's `set()` marks every reader of `selected` dirty again. Readers already checked in the current pass have to be checked again. Angular's effect guide warns that propagating state through effects can cause `ExpressionChangedAfterItHasBeenChecked` errors, circular updates and unnecessary change detection cycles. ## Problem 3: preserving the choice creates a trigger The natural next requirement is "keep the user's option if the new country still offers it": ```ts effect(() => { const opts = this.options(); const current = this.selected(); // tracked! this.selected.set(opts.find(o => o.id === current?.id) ?? opts[0]); }); ``` Reading `selected()` inside the effect makes it a dependency. Now every user selection re-runs the effect. Here it happens to write back an equivalent option, but any variation, such as falling back to a default when an id is missing or a race with a changing list, turns it into a reset of the user's click. The fix within effects is `untracked(this.selected)`, which is easy to forget. ## Problem 4: start-up and ownership - The effect **always runs at least once**, so any initial value, for example one restored from storage, is overwritten by `options()[0]` on the first pass. - Two writers now own `selected`: the user's handler and the effect. Readers cannot tell from the declaration which rule applies. ## How `linkedSignal()` fixes each one ```ts readonly selected = linkedSignal<ShippingOption[], ShippingOption>({ source: this.options, computation: (opts, previous) => opts.find(o => o.id === previous?.value.id) ?? opts[0], }); ``` | Problem | Effect-based reset | `linkedSignal()` | |---|---|---| | stale window | until the effect runs | none: reading recomputes when the source changed | | extra write and pass | yes | no write; the value is pulled | | preserving the choice | needs `untracked()` to avoid a trigger | `previous.value` is passed in, not tracked as a trigger | | initial value | overwritten on first run | defined by the computation | | ownership | two writers | one declaration shows the default rule; `set()` for the user | The reset happens **as part of reading**. Like `computed()`, a `linkedSignal()` checks whether its dependencies changed when it is read and recomputes if so. And `set()` first applies any pending recomputation before storing the new value, so a user's click right after a country change is not lost to a late reset. ## When an effect is still right If the reaction goes **out** of the signal graph, such as persisting the chosen option to storage or sending an analytics event, that is an effect's job. The rule of thumb: an effect that only writes signals from other signals is either a `computed()` or a `linkedSignal()` in disguise. ## How to spot it in review 1. Look for effects whose body is a single `set()` driven by other signals. 2. Ask whether the target is ever written elsewhere. If never, make it a `computed()`. If the user also writes it, make it a `linkedSignal()`. 3. Look for effects reading the signal they write; each one is a latent loop or a reset-on-click bug.
- In Angular, if the user calls selected.set() right after the country changed but before anything read selected(), is the choice lost?No. A linkedSignal's `set()` first brings the value up to date, applying the pending recomputation, and then stores the user's value. With a reset effect, by contrast, the effect could run afterwards and overwrite the click.
- In an Angular reset effect, why does reading selected() inside the effect cause trouble?Every signal an effect reads is a dependency, so the effect re-runs whenever the user changes the selection. Any branch that does not write back the same value then resets the user's click. `untracked()` avoids it; `linkedSignal()`'s `previous` avoids the problem by design.
saying these in an interview costs you the question
- An effect updates the selection synchronously when the country changes.
- A reset effect and a linkedSignal behave the same, just written differently.
- Reading the selection inside the reset effect has no side effects.
- linkedSignal() works by running an effect behind the scenes.
- Effects that only copy one signal into another are the recommended pattern.