feat(core): add custom set option to linkedSignal#68708
Conversation
|
Deployed adev-preview for f82284b to: https://ng-dev-previews-fw--pr-angular-angular-68708-adev-prev-7esgb0da.web.app Note: As new commits are pushed to this pull request, this link is updated after the preview is rebuilt. |
|
Is this the angular solution for deep signal? |
|
I think this is an anti-pattern. Why not use an The pattern being promoted here is essentially expressing a reaction:
That’s exactly what For more advanced patterns, where the logic needs to be encapsulated, I’d recommend something similar to what I implemented in my [Demo](https://stackblitz.com/edit/github-t13xquzq?file=src%2Fapp%2Fexamples%2Fplayground%2Fplayground.ts&initialpath=/playground) // case 1: declarative states
tempC = state(0, ({ set }) => {
effect(() => {
const valF = this.tempF();
set(((valF - 32) * 5) / 9);
});
return {
set,
};
});
tempF = state(
computed(() => (this.tempC() * 9) / 5 + 32),
({ set }) => ({ set })
);
// case 2: imperative change
tempCbis = state(0, ({ set }) => ({
set,
syncWithTempF: (valF: number) =>
set(((valF - 32) * 5) / 9),
}));
tempFbis = state(
computed(() => (this.tempCbis() * 9) / 5 + 32),
({ set }) => ({
set: (valF: number) => {
set(valF);
this.tempCbis.syncWithTempF(valF);
},
})
); |
|
@ronnain Using an effect would actually be frowned upon here. It even delays the write to the initial signal. |
|
Yeah, this is exactly the anti-pattern for
The setter override solves both problems. It acknowledges that tempFbis = state(
computed(() => (this.tempCbis() * 9) / 5 + 32),
({ set }) => ({
set: (valF: number) => {
set(valF);
this.tempCbis.syncWithTempF(valF);
},
})is doing the exact same thing - implementing |
|
I understand your arguments for not wanting to use an And those remaining edge cases can usually be avoided quite easily by customizing the If synchronization really needs to be strict, we could even avoid signals entirely and rely on observables instead. However, the Even though the update itself is imperative, the resulting state remains declarative — and that makes a huge difference. The current solution feels a bit like a lifecycle callback to me. That makes sense for asynchronous workflows, but feels much less relevant in synchronous scenarios. I’d also suggest considering a slightly different approach that could unlock much more advanced composition patterns. Instead of passing an object as the second argument, pass a callback function that receives utilities such as And similar to what I did with my That would allow highly declarative and composable state definitions. It would also open the door to many powerful patterns. For example, we could introduce a const tempF = linkedSignal(
() => (tempC() * 9) / 5 + 32,
({ set, value }) => ({
// expose custom setter
set: (valF: number) => {
set(valF);
tempC.set(((valF - 32) * 5) / 9);
},
isHot: computed(() => value() > 90),
}),
);
tempF(); // number
tempF.set(95);
tempF.isHot(); // booleanExample with composition: const tempF = linkedSignal(
() => 0,
pipe(
({ set, value }) => ({
set: (valF: number) => {
set(valF);
tempC.set(((valF - 32) * 5) / 9);
},
isHot: computed(() => value() > 90),
}),
withIncrement(),
withLogChange(),
withSyncLocalStorage(),
),
);You can go pretty far with this kind of approach. If you're interested, you can take a look at what I’m doing with [insertions](https://ng-angular-stack.github.io/craft/insertions/insert-select.html) in my library. Otherwise, without enabling declarative writable state composition, I don’t really see the value of the current solution as it stands. Inside a service or component, we can already do something like this: public readonly tempC = signal(0);
public readonly tempF = computed(...);
public setTempF() {
// update tempC
}Or: To conclude, it’s already fairly easy to avoid the problems originally raised. |
Introduce a custom `set` option in `linkedSignal` options to allow overriding and customizing the default write-back behavior of writable signals. This lets developers route updates back to the source of truth (e.g., converting Fahrenheit back to Celsius) or perform other side effects like updating properties inside a parent signal. Additionally, the custom callback receives the standard signal setter as its second parameter (`rawSet`) to allow direct internal mutation if desired. Fixes angular#59665 TAG=agy CONV=addbb5c4-4233-49e8-b844-6f732d7d5c72
|
Great news that you add this API to the framework. One suggestion regarding the signature: Simple: linkedSignal(mySourceSingnal)Extended: linkedSignal({
source: mySourceSignal,
computation: (value, prev) => value
})I would love to see the same for the set fn: linkedSignal(
mySourceSingnal,
mySameTypeSetter
)Extended: linkedSignal({
source: mySourceSignal,
computation: (value, prev) => value,
set: mySameTypeSetter
})The need of using the options object in the simple signature to define a set function is kind of bulky. This is also far better readable in case of factory implementations that do make sense in practice: |
|
Related as well #68280 |
|
I feel like the name of the property might not be descriptive enough. Was there any consideration for something like Also it's not fully clear from the documentation what happens if you update the source signal with a "wrong" source value (eg. you failed the the backwards conversion)? |
|
First, you do not necessarily update the source. |
Introduce a custom
setoption inlinkedSignaloptions to allow overriding and customizing the default write-back behavior of writable signals. This lets developers route updates back to the source of truth (e.g., converting Fahrenheit back to Celsius) or perform other side effects like updating properties inside a parent signal.Additionally, the custom callback receives the standard signal setter as its second parameter (
rawSet) to allow direct internal mutation if desired.TAG=agy
CONV=addbb5c4-4233-49e8-b844-6f732d7d5c72
PR Checklist
Please check if your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
Issue Number: N/A
What is the new behavior?
Does this PR introduce a breaking change?
Other information