`@ngrx/signals`: Add `withLinkedState`
Which @ngrx/* package(s) are relevant/related to the feature request?
signals
Information
Add the withLinkedState feature to enable linkedSignal-like behavior for NgRx SignalStore.
The withLinkedState feature would accept a callback that takes state signals, props, and computed signals as an input argument and returns a dictionary of linked signals. It also allows returning computation functions instead of linked signals to reduce repetitive code. In this case, linked signals will be created under the hood.
const OptionsStore = signalStore(
withState({ options: [1, 2, 3] }),
// option A: returning computation function
withLinkedState(({ options }) => ({
selectedOption: () => options()[0] || undefined,
})),
// option B: returning `linkedSignal`
withLinkedState(({ options }) => ({
selectedOption: linkedSignal({
source: () => /* ... */,
computation: () => /* ... */,
}),
})),
// linked state becomes integral part of the SignalStore state:
withMethods((store) => ({
setSelectedOption(selectedOption: number): void {
patchState(store, { selectedOption });
},
})),
// signals for linked state slices are available as other state signals:
withHooks({
onInit({ selectedOption }) {
console.log(selectedOption());
},
}),
);
Breaking Change
Since it's not possible to synchronously track signal changes, it's necessary to change the internal implementation of SignalStore's STATE_SOURCE to enable this functionality. Instead of saving the whole state in a single writable signal, it needs to be stored in a dictionary of writable signals for each state slice.
The change of the internal STATE_SOURCE implementation should not break most of the SignalStore users. The breaking change is expected only in the case where SignalStore's state is defined as a dictionary with dynamic state slices:
const NumbersStore = signalStore(
withState<Record<number, number>>({}),
withMethods((store) => ({
addNumber(num: number): void {
patchState(store, { [num]: num });
}
})),
);
const store = inject(NumbersStore);
console.log(getState(store)); // {}
store.addNumber(1);
store.addNumber(2);
// v19:
console.log(getState(store)); // { 1: 1, 2: 2 }
// v20:
console.log(getState(store)); // {}
In v20, it won't be possible to add root state slices dynamically. To make it work again, it's necessary to add a root state slice that has a dictionary with dynamic keys:
const NumbersStore = signalStore(
withState<{ numbers: Record<number, number> }>({ numbers: {} }),
withMethods((store) => ({
addNumber(num: number): void {
patchState(store, { numbers: { ...store.numbers(), [num]: num } });
}
})),
);
const store = inject(NumbersStore);
console.log(getState(store)); // { numbers: {} }
store.addNumber(1);
store.addNumber(2);
console.log(getState(store)); // { numbers: { 1: 1, 2: 2 } }
Describe any alternatives/workarounds you're currently using
effect can be used to update SignalStore's state every time the linked signal is changed. However, effect does not provide the ability to synchronously track signal changes, so the state would be updated in the next tick. This would differentiate from the original behavior of linked signals that where the update is available immediately.
I would be willing to submit a PR to fix this issue
- [ ] Yes
- [ ] No
I would like to do that. Based on some internal draft, I would split this up into two PRs. One, which splits the state into multiple Signals and a second one which introduces the linkedState feature.
@markostanimirovic, @timdeschryver
While implementing withLinkedState, I realized that we should support the use case where a manual linkedSignal (Option B) can returns an object literal that makes up all or parts of the state.
At the moment, that's not the best DX.
Consider a BasketStore that should reset when a user ID in a UserStore changes. The current implementation would look like this:
const UserStore = signalStore(
withState({ id: 1 })
)
const BasketStore = signalStore(
withLinkedState(() => {
const userStore = inject(UserStore);
return { basket: linkedSignal({
source: userStore.id,
computation: () => [] as Array<{ productId: number, amount: number }>
})}
}),
withLinkedState(() => {
const userStore = inject(UserStore);
return { lastUpdated: linkedSignal({
source: userStore.id,
computation: () => new Date()
})}
}),
)
Another example could be within a store, where a change to a property id resets the rest of the state:
const UserStore = signalStore(
withState({ id: 1 }),
withLinkedState(({ id }) => ({
firstname: linkedSignal({
source: id,
computation: () => '',
}),
})),
withLinkedState(({ id }) => ({
lastname: linkedSignal({
source: id,
computation: () => '',
}),
}))
);
I am proposing to change Option B that the linkedSignal is not assigned to a property in the return object literal, but linkedSignal returns the full state:
const UserStore = signalStore(
withState({ id: 1 }),
withLinkedState(({ id }) =>
linkedSignal({
source: id,
computation: () => ({ firstname: '', lastname: '' }),
})
)
);
const BasketStore = signalStore(
withLinkedState(() => {
const userStore = inject(UserStore);
return linkedSignal({
source: userStore.id,
computation: () => ({
basket: [] as Array<{ productId: number; amount: number }>,
lastUpdated: new Date(),
}),
});
})
);
That would also mean that Option B as described, would not be possible with linkedState. Instead, users can just go directly with withState:
const OptionsStore = signalStore(
withState({ options: [1, 2, 3] }),
// option B: returning `linkedSignal`
withState(({ options }) => ({
selectedOption: linkedSignal({
source: () => options,
computation: (options) => options()[0] ?? undefined,
}),
}))
);
That would also require that stateFactory in withState has access to the stateSignals.
I am going to submit a PR which supports that already (because it is the more difficult version). The PR will miss the change to withState, though.
As discussed internally, we do not want to encourage wrapping larger parts of the state into a linkedSignal. This decision is primarily driven by performance concerns.
When the entire state is bound to a single signal, any change - regardless of which part - is tracked through that one signal. This means all direct consumers are notified, even if only a small slice of the state actually changed.
Instead, each root property of the state be a signal on its own, allowing for more granular reactivity and significantly reducing unnecessary updates.
❌ Suboptimal: All-in-one signal
const state = signal({
user: {
firstname: 'Joe',
lastname: 'Smith'
},
location: {
city: 'Houston',
country: 'US'
}
});
- Any change (e.g.
user.firstname) triggers the entirestatesignal.
✅ Preferred: One signal per slice
const state = {
user: signal({
firstname: 'Joe',
lastname: 'Smith'
}),
location: signal({
city: 'Houston',
country: 'US'
})
};
- Only consumers of the affected slice are notified.
A construct like:
const state = linkedSignal(() => ({ user, location }));
reintroduces the same centralization problem and should therefore be discouraged by design.
We therefore stay with the original spec.