Let's be honest: linkedSignal() always had a bit of a split personality. It read beautifully from another signal, deriving a value that stayed in sync automatically. But the moment you needed to write back to that source, you were on your own, writing a separate method, converting the value by hand, and hoping you didn't forget to call it from every place that touched the field.
Angular 22.1 closes that gap. linkedSignal() can now take a custom set() option, and the linkedSignal becomes a genuine two-way field: read from a larger piece of state, write back to it, no disconnected duplicate copy of the data anywhere.
My first reaction when I saw this: if the linkedSignal reads a source signal, and writing to the linkedSignal updates that same source, doesn't that loop forever? It didn't feel obvious to me, so I went and traced through the actual reactive graph source to settle it, and that turned into a nice little tour of how Angular's signals actually work.
✅ Availability: the custom
set()option shipped in Angular 22.1.0 (stable) and is documented in the officiallinkedSignalguide.
🧩 Before: Reading Was Clean, Writing Wasn't
Here's the classic shape. A profile signal holds the real state, and a linkedSignal exposes one field of it:
protected readonly profile = signal<UserProfile>({
id: 1,
name: 'Brian',
email: 'brian@example.com',
maxMarketingEmailsPerWeek: 2,
});
protected readonly maxMarketingEmailsPerWeek = linkedSignal(
() => this.profile().maxMarketingEmailsPerWeek
);
Reading maxMarketingEmailsPerWeek() is effortless. Writing back to profile is not: you need a second method that does the profile.update() dance, and every button, every input handler, has to remember to call that method instead of the linkedSignal directly. The read side and the write side live in two different places for what is conceptually one piece of state.
🔧 After: A Custom set()
The fix is a set function passed alongside the computation:
protected readonly maxMarketingEmailsPerWeek = linkedSignal(
() => this.profile().maxMarketingEmailsPerWeek,
{
set: (value) => {
this.profile.update((profile) => ({
...profile,
maxMarketingEmailsPerWeek: value,
}));
},
}
);
Now maxMarketingEmailsPerWeek.set(5) and .update(v => v + 1) both flow through that setter and land on profile. The helper method is gone. The simplest version of this idea, straight from the Angular docs, is a temperature converter:
const tempC = signal(0);
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
set: (valF) => tempC.set(((valF - 32) * 5) / 9),
});
tempF.set(212);
console.log(tempC()); // 100
console.log(tempF()); // 212
🧠 Wait, Doesn't This Loop?
Here's where I got stuck. tempF reads tempC() inside its computation, so tempF is a genuine dependent of tempC in the reactive graph. When tempC.set(100) runs inside the custom setter, that dependency absolutely gets marked dirty and tempF absolutely does recompute. If it didn't, tempF() would show a stale value after the conversion, and the whole feature would be pointless.
So the question I actually needed answered was: does that recompute call the custom setter again? It doesn't, and the reason is architectural, not incidental.
🔍 Under the Hood: The Write Path
linkedSignal's public write API, defined in linked_signal.ts, looks like this once a custom setter is supplied:
const rawSet = (newValue: D) => linkedSignalSetFn(node, newValue);
upgradedGetter.set = (newValue: D) => customSet(newValue, rawSet);
upgradedGetter.update = (updateFn: (value: D) => D) =>
customSet(updateFn(untracked(getter)), rawSet);
Calling tempF.set(212) invokes exactly one thing: your customSet function. It never touches tempF's own internal value directly, unless your function explicitly calls the rawSet escape hatch it's handed. In the temperature example, it doesn't, it only calls tempC.set(100).
The temperature example never reaches for that second parameter, but there are two situations where you would. One is performance: when computation is expensive and you already know the exact result, rerunning it is wasted work. The other is less obvious: not every linkedSignal has an invertible relationship with its source the way tempF/tempC do. Plenty of real computation functions, summarizing, rounding, picking one field out of several possible source states, can't be inverted into a single correct value to write back. rawSet covers both: it's a direct line to the exact primitive that backs the default .set() when there's no custom setter at all, letting you plant the value directly instead of inverting through the source:
} else {
upgradedGetter.set = (newValue: D) => linkedSignalSetFn(node, newValue);
upgradedGetter.update = (updateFn) => linkedSignalUpdateFn(node, updateFn);
}
rawSet is (newValue) => linkedSignalSetFn(node, newValue), the same call, just handed to you as an argument instead of wired up automatically. Here's what that function actually does, from the signal primitives:
export function linkedSignalSetFn<S, D>(node: LinkedSignalNode<S, D>, newValue: D) {
producerUpdateValueVersion(node); // resolve any pending staleness first
signalSetFn(node, newValue); // plain field write: node.value = newValue, version++
producerMarkClean(node); // mark the node up to date
}
Three steps: settle any recompute that's already overdue, write node.value directly with the same primitive a plain signal() uses (never touching node.computation or node.sourceValue), then mark the node clean.
That first step is worth pausing on, because "settle any overdue recompute" is literal. If the linkedSignal is currently dirty, its source changed since anyone last read it, producerUpdateValueVersion runs producerRecomputeValue before your value ever gets written, which means computation fires once, and if it has any observable side effects, they happen. Your value always wins in the end, the freshly recomputed result gets overwritten immediately after, but "computation never runs" isn't quite true in that specific case. The mark-clean step is what matters for everything after this call: it tells the next read's staleness check that the node is current, so from then on producerRecomputeValue is skipped, regardless of what the source is doing in the meantime.
Line them up and there are three distinct write paths, not two:
.set() |
What happens | computation |
|---|---|---|
| Default, no custom setter | Calls linkedSignalSetFn(node, v) directly. |
Does not run. Exception: if the node was already stale due to an earlier unread source change, it runs once first to settle the pending recomputation. |
| Custom setter, setter writes to the source | Only the source's version is bumped. This node is marked dirty but is otherwise untouched. | Re-runs lazily, the next time the linkedSignal is read. |
Custom setter, setter calls rawSet(v)
|
Identical to the default case: calls linkedSignalSetFn(node, v). |
Does not run, except when there is already a pending stale recomputation that must settle first. |
Here's the performance case: a custom setter with independent knowledge of the right answer, where redoing the computation would be wasted work:
const items = signal<Item[]>(hugeInitialList);
const sortedItems = linkedSignal(
() => expensiveSort(items()), // recomputes whenever items() changes
{
set: (value, rawSet) => {
// We just got an already-sorted page back from the server —
// no need to re-run expensiveSort() for a value we already know.
rawSet(value);
},
},
);
sortedItems.set(pageFromServer); // writes directly, skipping a fresh expensiveSort() run
Compare that to the temperature example: there, the setter calls tempC.set(...) because it genuinely needs tempF to re-derive from the new tempC. Here, the setter already has the final value, so it bypasses the derivation instead of inverting it.
And here's the other case, where inverting through the source isn't just wasteful, it doesn't make sense at all:
const rawInput = signal('42.7');
const roundedValue = linkedSignal(
() => Math.round(Number(rawInput())),
{
set: (value, rawSet) => {
// Multiple rawInput strings round to the same integer, there's
// no single correct string to write back, so just store the value.
rawSet(value);
},
},
);
Rounding is lossy: many different rawInput values produce the same roundedValue, so there's no correct string for the setter to write back to rawInput. rawSet sidesteps the question entirely, it's the same direct write the default .set() would give you if there were no custom setter at all.
Both cases share the same shape: rawSet is for when inverting through the source is either wasteful or doesn't make sense, and you'd rather just set the value. It also keeps a single public method for callers: .set(x) works the same way from the outside whether this particular value is a normal edit that should flow through the source, or a precomputed result the setter recognizes and stores directly. The linkedSignal, not the caller, decides which applies.
That's the entire write path, everything .set() and .update() can reach, custom setter or not. rawSet can reach into the recompute machinery to settle a pending stale value, but never the other way around.
⚙️ Under the Hood: The Recompute Path
The recompute lives somewhere else entirely, in the signal primitives:
const linkedSignalGetter = () => {
producerUpdateValueVersion(node); // check staleness, recompute if needed
producerAccessed(node);
return node.value;
};
Every signal getter, plain signal(), computed(), or linkedSignal(), is shaped this way. producerUpdateValueVersion checks whether any dependency's version changed since the last read, and if so, calls producerRecomputeValue, which reruns the computation and assigns straight to node.value as a plain field write:
newValue = node.computation(newSourceValue, prev);
node.sourceValue = newSourceValue;
// ...
node.value = newValue;
node.version++;
That's the whole answer. tempC.set(100) only flips a dirty flag and bumps a version counter, no computation runs at that moment. The actual recomputation is deferred until the next time something reads tempF(), and when it happens, it happens through producerRecomputeValue, a function that has never heard of customSet. Not directly, not indirectly. There's no code path in the primitives package that leads back to your setter. That direction is the one that would actually matter: if recomputation could call back into customSet, that's the loop you'd have to worry about, and it provably can't. The reverse isn't quite as absolute, rawSet can call into producerRecomputeValue to settle a pending stale value, as covered above, but even then it only ever reaches this same recompute logic, never back into your setter. That asymmetry, not a strict mutual isolation, is the whole reason a linkedSignal can safely both read from and write to the same piece of state.
One detail worth keeping in mind: this recompute isn't scheduled for later, it's synchronous and pull-based. If tempF() is read inside another computed(), the staleness check and recompute for tempF happen inline, in the same call stack, before that outer computed can finish. Angular's signals are lazy all the way down: nothing recalculates until someone actually asks for a value, but once something does ask, the whole stale chain resolves immediately, in dependency order.
✅ Closing Thoughts
So, back to the question that opened this piece: no, it doesn't loop, and now you know exactly why: producerRecomputeValue can't reach back into customSet, only rawSet reaches the other way. That one-way gap is what makes it safe.
The custom set() option is a small addition on paper, one new field in an options object, but it changes what linkedSignal() is for. Before, it was a read-only derivation with a writable escape hatch bolted on by convention (the default set/update, with no custom setter, just overwrites the linkedSignal's own value directly). Now it can be the field-level API for a slice of larger state, without ever creating a second source of truth. And it does that safely because Angular's signal graph keeps "someone wrote to me" and "one of my dependencies changed" as two structurally separate events, not because of anything defensive in the code you write.
If you haven't read my earlier piece comparing computed() and linkedSignal(), that's the conceptual "when to reach for which" companion to this one, this article is the deep end: the internals that explain why the two-way version is safe.
If you're touching linkedSignal() in a codebase that also uses Signal Forms or resource(), the same lazy, pull-based recompute model is running underneath all of them. Worth understanding once, pays off everywhere.
If you found this helpful, follow me here and on LinkedIn for more deep dives into Angular, web performance, and modern frontend development.
See you in the next one! 🤙🏻
— G.
Top comments (0)