platform
platform copied to clipboard
feat(signals)!: allow user-defined signals in `withState` and `signalState` by splitting `STATE_SOURCE`
BREAKING CHANGE: withState and signalState now support user-defined signals like linkedSignal, resource.value, or any other WritableSignal.
For example:
const user = signal({ id: 1, name: 'John Doe' });
const userClone = linkedSignal(user);
const userValue = resource({
loader: () => Promise.resolve('user'),
defaultValue: ''
});
const Store = signalStore(
withState({ user, userClone, userValue: userValue.value })
);
The state slices don't change:
store.user; // DeepSignal<{ id: number, name: string }>
store.userClone; // DeepSignal<{ id: number, name: string }>
store.userValue; // Signal<string>
The behavior of linkedSignal and resource is preserved. Since the SignalStore no longer creates the signals internally in these cases, signals passed into withState can also be changed externally.
This is a foundational change to enable features like withLinkedState (#4639) and potential support for withResource.
The internal STATE_SOURCE is no longer represented as a single WritableSignal holding the entire state object. Instead, each top-level property becomes its own WritableSignal, or remains as-is if the user already provides a WritableSignal.
Motivation
- Internal creation of signals limited flexibility; users couldn’t bring their own signals into the store
- Reusing existing signals enables future features like
withLinkedStateorwithResource. - Splitting state into per-key signals improves the performance, because the root is not the complete state anymore.
Change to STATE_SOURCE
Given:
type User = {
firstname: string;
lastname: string;
};
Before
STATE_SOURCE: WritableSignal<User>;
Now
STATE_SOURCE: {
firstname: WritableSignal<string>;
lastname: WritableSignal<string>;
};
Breaking Changes
1. Different object reference
The returned object from signalState() or getState() no longer keeps the same object identity:
const obj = { ngrx: 'rocks' };
const state = signalState(obj);
Before:
state() === obj; // ✅ true
Now:
state() === obj; // ❌ false
2. No signal change on empty patch
Empty patches no longer emit updates, since no signal is mutated:
const state = signalState({ ngrx: 'rocks' });
let count = 0;
effect(() => count++);
TestBed.flushEffects();
expect(count).toBe(1);
patchState(state, {});
Before:
expect(count).toBe(2); // triggered
Now:
expect(count).toBe(1); // no update
3. No wrapping of top-level WritableSignals
const Store = signalStore(
withState({ foo: signal('bar') })
);
const store = new Store();
Before:
store.foo; // Signal<Signal<string>>
Now:
store.foo; // Signal<string>
4.: patchState no longer supports Record as root state
Using a Recordas the root state is no longer supported by patchState.
Before:
const Store = signalStore(
{ providedIn: 'root' },
withState<Record<number, number>>({}),
withMethods((store) => ({
addNumber(num: number): void {
patchState(store, {
[num]: num,
});
},
}))
);
store.addNumber(1);
store.addNumber(2);
expect(getState(store)).toEqual({ 1: 1, 2: 2 });
After:
const Store = signalStore(
{ providedIn: 'root' },
withState<Record<number, number>>({}),
withMethods((store) => ({
addNumber(num: number): void {
patchState(store, {
[num]: num,
});
},
}))
);
store.addNumber(1);
store.addNumber(2);
expect(getState(store)).toEqual({}); // ❌ Nothing updated
If dynamic keys are needed, consider managing them inside a nested signal instead.
Further Changes
signalStoreFeatureupdated due to changes inWritableStateSourcepatchStatenow usesNoInferonupdatersto prevent incorrect type inference when chaining
PR Checklist
Please check if your PR fulfills the following requirements:
- [x] The commit message follows our guidelines: https://github.com/ngrx/platform/blob/main/CONTRIBUTING.md#commit
- [x] Tests for the changes have been added (for bug fixes / features)
- [] Documentation has been added / updated (for bug fixes / features)
PR Type
What kind of change does this PR introduce?
[ ] Bugfix
[x] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Build related changes
[ ] CI related changes
[ ] Documentation content changes
[ ] Other... Please describe:
What is the current behavior?
Closes #
What is the new behavior?
Does this PR introduce a breaking change?
[x] Yes
[ ] No
Other information
Deploy Preview for ngrx-io ready!
Built without sensitive environment variables
| Name | Link |
|---|---|
| Latest commit | 5b95c9508b979619668eef8ee22fe0ee0a08c045 |
| Latest deploy log | https://app.netlify.com/projects/ngrx-io/deploys/686092c8a6a8ba0008cf70c4 |
| Deploy Preview | https://deploy-preview-4795--ngrx-io.netlify.app |
| Preview on mobile | Toggle QR Code...Use your smartphone camera to open QR code link. |
To edit notification comments on pull requests, go to your Netlify project configuration.
Deploy Preview for ngrx-site-v19 ready!
| Name | Link |
|---|---|
| Latest commit | 5b95c9508b979619668eef8ee22fe0ee0a08c045 |
| Latest deploy log | https://app.netlify.com/projects/ngrx-site-v19/deploys/686092c8bd5a3c00087b28ee |
| Deploy Preview | https://deploy-preview-4795--ngrx-site-v19.netlify.app |
| Preview on mobile | Toggle QR Code...Use your smartphone camera to open QR code link. |
To edit notification comments on pull requests, go to your Netlify project configuration.
@rainerhahnekamp It's also necessary to write BREAKING CHANGES: ... in a plain text format before the Other Information section. It will be copied to the commit body on squash merge. See the example here: https://github.com/ngrx/platform/pull/4584
It's also necessary to write BREAKING CHANGES: ...
Got it: Plural and then a line break.
It looks like my IDE messed up the subject a little bit. Will check that one as well.
@markostanimirovic, I've updated the code or - where applicable - answered your comments. Please check, once you have time.
I've also fixed a bug in af974f9b.
I see commits from main have been merged. Can I rebase them instead or does GitHub do that automatically meanwhile?
@markostanimirovic, I've updated the code or - where applicable - answered your comments. Please check, once you have time.
I've also fixed a bug in af974f9.
I see commits from main have been merged. Can I rebase them instead or does GitHub do that automatically meanwhile?
You can sync changes from main manually and push the changes, or use the "Update branch" button which is available on the PR page.
Btw, lint is also failing.
@markostanimirovic, @timdeschryver
signalStatenow also support user-defined writable Signals- I've updated the docs, by adding an example in
signalStatefor both old and new website.
I will push the PR for withLinkedState later today.
@markostanimirovic: I think we can start a new review round. There weren't actually that many changes:
- Applied your suggestions
- Added two tests for
withState - Removed the "equal check" in
patchState - Added an alert infobox about user-defined Signals for
withState