Page loaded: Miguel Carino | Angular Front-End Architect

Photo by Spencer DeMera on Unsplash

State & Reactivity

A Selection That Went Stale Every Refresh

Some state is derived and writable at the same time. Forcing it into a computed or an effect is what kept breaking the detail panel.

Miguel Carino
Focus
Front-end architecture, Angular, team leadership, and stakeholder communication
Experience
25+ years turning enterprise web complexity into maintainable products

The bug report was four words long: "detail panel shows nothing." It came with a screenshot of a claims table, a filter set to Open, and a detail pane on the right that was completely blank — no record, no empty state, just the panel chrome sitting there like a picture frame with the photo removed.

It reproduced on the first try, which is always either a relief or a bad sign. Click a row. Read the details on the right. Change the filter from All to Open. The table refreshes correctly, the selected row is no longer among the rows — it was a closed claim — and the detail panel keeps rendering the claim it was told about several seconds ago. The selection was pointing at a record the list no longer contained.

The table was right. The panel was right. The thing between them was holding a stale answer.

What made it awkward is that both obvious fixes were things I'd deliberately chosen earlier. The selection was a plain writable signal, because the user picks it — that's the whole feature. And the filtered rows were a computed, because they're derived from the filter and the source data. Each decision was defensible on its own. The bug lived in the space between them, in a piece of state I had never actually classified: something the user writes, that also has to answer to something else.

Derived and writable are not opposites

I had been sorting state into two bins for years. Source state, which someone writes. Derived state, which is computed from source state and which nobody writes. That taxonomy covers most of an application, and when it doesn't fit, you tend to blame the feature rather than the taxonomy.

A selection is genuinely both. It's derived, because when the list of candidates changes the selection has to answer for itself — a claim that isn't in the list can't be the selected claim. It's also writable, because the entire point is that a person clicks a row and overrides whatever default was there. Those aren't in tension; they're two different moments. The source change resets it, and between resets the user owns it.

That's the shape linkedSignal was added for. It's a writable signal whose value comes from a computation, and it re-derives when that computation's sources change, yet you can still call set() on it in between. Angular introduced it in v19 as a developer preview and it went stable in v20, so on any currently supported version it's a normal part of the toolkit rather than something you have to justify.

The naive form is one line, and it fixes the blank panel immediately:

const claims = signal([{ id: 'c-1' }, { id: 'c-2' }]);
const selected = linkedSignal(() => claims()[0]);

Read that as: the selected claim is the first one, and whenever claims changes it becomes the first one again. The detail panel can never be handed a record outside the current list, because every refresh of the list resets the selection into it.

Which cured the bug and introduced a smaller, more annoying one. Now every filter change threw away the user's click. Change the filter to Open, and if your selected claim was open — still right there in the list — you'd get bounced back to row one anyway. Fixing a blank panel by discarding the user's intent is not a fix. It's a trade.

The version I shipped uses the explicit source and computation form, where the computation receives the previous value and gets to decide whether to keep it:

type Claim = { id: string; status: 'open' | 'closed' };

const visibleClaims = signal<Claim[]>([]);

const selectedClaim = linkedSignal<Claim[], Claim | undefined>({
  source: visibleClaims,
  // keep the user's pick if it survived the refresh; otherwise fall back
  computation: (claims, previous) =>
    claims.find((c) => c.id === previous?.value?.id) ?? claims[0],
});

The previous argument is what makes this more than a reset. It carries the value from before the source changed, so "did the selection survive?" becomes a lookup by id rather than a guess. And the behavior a user sees now reads like a sentence: filter to Open while an open claim is selected and the selection holds, detail panel unchanged. Filter to Open while a closed claim is selected and it can't hold, so it falls back to the first visible row. Click any row and it's yours until the list changes underneath you again.

The call site never learns any of this. The detail panel takes selectedClaim() and renders it; the row click calls selectedClaim.set(claim) like it always did. Only the declaration changed, and the invariant — the selection is always something in the list — moved out of everybody's head and into the one place that owns it.

The effect version works, right up until it doesn't

There are three ways to attack this, and only one of them I'd defend.

Keeping computed() is the cleanest-sounding option, and it's genuinely correct about the derivation. It just can't accept the click. A computed is read-only by design, so the moment the user needs to override the default you're back to a second signal holding the override plus branching logic to decide which of the two to read. I've written that pair. It's two sources of truth for one idea, and it goes wrong in the direction where the override sticks around after it stopped being valid.

The alternative most teams reach first — the one I reached for that afternoon before I stopped myself — is a plain signal for the selection plus an effect that watches the source and resets it. It works. It'll pass the ticket, and it'll pass code review. What it costs is that you've moved a derivation into an imperative callback that runs after the fact. Angular's signals guide recommends reaching for a derivation, not an effect, when you want to react to a state change — and the reasons show up later rather than immediately. The reset now happens in a separate pass from the change that caused it, which is precisely the ordering seam where "the panel flickered the old record for one frame" lives. Add a second effect that also touches the selection and the order between them becomes something you have to reason about rather than something the framework guarantees. Worse, the invariant is no longer visible where the state is declared — you read signal<Claim>(), believe it, and have to go find the effect somewhere else to learn the real rule. The trap isn't that effects are bad. It's that an effect turns a fact about your state into a side-effect you have to maintain.

linkedSignal wins here because the invariant lives in the declaration. There's one signal, so there's nothing to keep in sync; the reset is part of the read model instead of a reaction to it; and a reader who opens the file learns the rule from the four lines that define the state. The honest limits: it's for state whose default is derived and whose overrides are short-lived, which is why it fits selections, resettable form fields, and paging offsets that reset per query. If nothing ever writes it, use computed. If nothing ever resets it, use signal. And if the reaction has to reach outside the reactive graph — a fetch, an analytics call, focus management — that's still effect territory, because that isn't state derivation at all.

Some State Answers to Two Owners

The blank panel wasn't a signals bug. It was a modeling bug I'd been carrying since before signals, back when the same class of problem showed up as a stale property on a component and a ngOnChanges hook trying to babysit it. Sorting state into "the user writes it" and "the app derives it" is a good instinct that happens to be incomplete, and the state it can't classify is where the ghosts collect. A selection, a default that's editable, an offset that resets per query — these answer to two owners, the source that resets them and the person who overrides them in between. Name that category when you see it, declare the reset next to the state instead of in a callback that runs later, and the panel stops rendering records that aren't there.