-
-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(signals): allow user-defined signals in withState
and signalState
by splitting STATE_SOURCE
#4795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(signals): allow user-defined signals in withState
and signalState
by splitting STATE_SOURCE
#4795
Conversation
✅ Deploy Preview for ngrx-io canceled.Built without sensitive environment variables
|
✅ Deploy Preview for ngrx-site-v19 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
3ee3f3b
to
4f6c28b
Compare
STATE_SOURCE
STATE_SOURCE
4f6c28b
to
5d23f0c
Compare
modules/signals/src/with-state.ts
Outdated
); | ||
return { ...acc, [key]: toDeepSignal(sliceSignal) }; | ||
}, {} as SignalsDictionary); | ||
const stateAsRecord = state as Record<string | symbol, unknown>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can do explicit casting on line 50 and remove this variable.
@rainerhahnekamp It's also necessary to write |
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. |
1057bd7
to
be6190f
Compare
@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 Btw, lint is also failing. |
574c406
to
8fd5c51
Compare
STATE_SOURCE
withState
and signalState
by splitting STATE_SOURCE
26c18a2
to
5f159a1
Compare
@markostanimirovic, @timdeschryver
I will push the PR for |
ec120da
to
3b86461
Compare
…State` BREAKING CHANGES: `withState` and `signalState` now support user-defined signals like `linkedSignal`, `resource.value`, or any other `WritableSignal`. For example: ```ts 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: ```ts st 6DAF ore.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` 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 `withLinkedState` or `withResource`. - Splitting state into per-key signals improves the performance, because the root is not the complete state anymore. ## Change to `STATE_SOURCE` Given: ```ts type User = { firstname: string; lastname: string; }; ``` ### Before ```ts STATE_SOURCE: WritableSignal<User>; ``` ### Now ```ts 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: ```ts const obj = { ngrx: 'rocks' }; const state = signalState(obj); ``` **Before:** ```ts state() === obj; // ✅ true ``` **Now:** ```ts state() === obj; // ❌ false ``` --- ### 2. No signal change on empty patch Empty patches no longer emit updates, since no signal is mutated: ```ts const state = signalState({ ngrx: 'rocks' }); let count = 0; effect(() => count++); TestBed.flushEffects(); expect(count).toBe(1); patchState(state, {}); ``` **Before:** ```ts expect(count).toBe(2); // triggered ``` **Now:** ```ts expect(count).toBe(1); // no update ``` --- ### 3. No wrapping of top-level `WritableSignal`s ```ts const Store = signalStore( withState({ foo: signal('bar') }) ); const store = new Store(); ``` **Before:** ```ts store.foo; // Signal<Signal<string>> ``` **Now:** ```ts store.foo; // Signal<string> ``` --- ### 4.: `patchState` no longer supports `Record` as root state Using a `Record`as the root state is no longer supported by `patchState`. **Before:** ```ts 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:** ```ts 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 - `signalStoreFeature` updated due to changes in `WritableStateSource` - `patchState` now uses `NoInfer` on `updaters` to prevent incorrect type inference when chaining
Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com>
3b86461
to
ee3d751
Compare
Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work Rainer!
Please check this comment: #4795 (comment)
It's necessary to add "BREAKING CHANGES: ..." at the end of the issue description in a plain text format. This will be copied in a commit message body.
import { | ||
computed, | ||
isSignal, | ||
linkedSignal, | ||
resource, | ||
signal, | ||
} from '@angular/core'; | ||
import { TestBed } from '@angular/core/testing'; | ||
import { withComputed, withMethods, withState } from '../src'; | ||
import { STATE_SOURCE } from '../src/state-source'; | ||
import { getInitialInnerStore } from '../src/signal-store'; | ||
import { getInitialInnerStore, signalStore } from '../src/signal-store'; | ||
import { getState, patchState } from '../src/state-source'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check unused imports. Most of them them seem unused. Also, public APIs, like getState
, patchState
, etc. should be imported from the ../src
path.
Let's also add one or multiple unit tests (without signalStore
usage) for withState
to validate the changed behavior.
}); | ||
}); | ||
|
||
it('should only patch affected root properties', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: for consistency
it('should only patch affected root properties', () => { | |
it('patches only affected root properties', () => { |
@@ -192,6 +294,27 @@ describe('StateSource', () => { | |||
expect(executionCount).toBe(2); | |||
}); | |||
}); | |||
|
|||
it('does not support a dynamic type as state', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it('does not support a dynamic type as state', () => { | |
it('does not support a dynamic dictionary as state', () => { |
@@ -344,4 +467,125 @@ describe('StateSource', () => { | |||
}); | |||
}); | |||
}); | |||
|
|||
describe('integration of Signals natively', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this description would be more precise:
describe('integration of Signals natively', () => { | |
describe('user-defined signals as state slices', () => { |
expect(store.user()).toEqual({ id: 2, name: 'John Doe' }); | ||
}); | ||
|
||
it('allows mixed writable Signal Types', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it('allows mixed writable Signal Types', () => { | |
it('allows mixing signals and plain values', () => { |
'@ngrx/signals: Skipping update for unknown property in state source.', | ||
`Property: ${String(key)}` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"state source" may be confusing for users who are not familiar with SignalStore/State internal implementation. Message:
'@ngrx/signals: Skipping update for unknown property in state source.', | |
`Property: ${String(key)}` | |
`@ngrx/signals: patchState was called with an unknown state slice '${String(key)}'.`, | |
'Ensure that all state properties are explicitly defined in the initial state.', | |
'Updates to properties not present in the initial state will be ignored.', |
if (currentState[signalKey] === newState[signalKey]) { | ||
continue; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this check can be removed. It already exists in set fn implementation: https://github.com/angular/angular/blob/main/packages/core/primitives/signals/src/signal.ts#L91
signal: unknown | ||
): signal is WritableSignal<unknown> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
input can be any value, not just a signal, so we can rename it to value
or something similar:
signal: unknown | |
): signal is WritableSignal<unknown> { | |
value: unknown | |
): value is WritableSignal<unknown> { |
When a state property holds an object as its value, the `signalState` function generates a `DeepSignal`. | ||
It can be used as a regular read-only signal, but it also contains signals for each property of the object it refers to. | ||
|
||
```ts | ||
const firstName = user.firstName; // type: Signal<string> | ||
const lastName = user.lastName; // type: Signal<string> | ||
|
||
console.log(firstName()); // logs: 'Eric' | ||
console.log(lastName()); // logs: 'Clapton' | ||
``` | ||
|
||
If the root properties of a state are already a `WritableSignal`, then they are reused instead of creating new signals. | ||
This allows the integration of external `WritableSignal`s, such as `linkedSignal` or `resource.value`. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The previous paragraph and example are duplicated here accidentally. The example for the new signalState
behavior is missing.
Let's leave 'Creating a SignalState' section as it is, and after 'Custom State Updaters' (before 'Usage') add a new section. Note: the new section should be added after 'Updating State' section because the example uses patchState
. I also suggest not mentioning implementation details, like - "they are reused instead of creating new signals".
## Using User-Defined Signals
The `signalState` function supports integrating user-defined `WritableSignal` instances as part of the state.
If a root state property is a `WritableSignal`, its value becomes an integral part of the state.
The SignalState instance and the original signal remain synchronized - updating one will immediately reflect in the other.
\```ts
const name = signal('Jimi Hendrix');
const userState = signalState({
// 👇 Providing an external signal as part of the initial state.
name,
isAdmin: false,
});
console.log(userState.name()); // logs: Jimi Hendrix
// Updating the external signal
name.set('Brian May');
// reflects the value in the SignalState instance.
console.log(userState.name()); // logs: Brian May
// Updating the SignalState instance
patchState(userState, { name: 'Eric Clapton' });
// reflects the value in the external signal.
console.log(name()); // logs: Eric Clapton
\```
We should also add an alert/paragraph with similar content for withState
.
withState
and signalState
by splitting STATE_SOURCE
withState
and signalState
by splitting STATE_SOURCE
BREAKING CHANGE:
withState
andsignalState
now support user-defined signals likelinkedSignal
,resource.value
, or any otherWritableSignal
.For example:
The state slices don't change:
The behavior of
linkedSignal
andresource
is preserved. Since the SignalStore no longer creates the signals internally in these cases, signals passed intowithState
can also be changed externally.This is a foundational change to enable features like
withLinkedState
(#4639) and potential support forwithResource
.The internal
STATE_SOURCE
is no longer represented as a singleWritableSignal
holding the entire state object. Instead, each top-level property becomes its ownWritableSignal
, or remains as-is if the user already provides aWritableSignal
.Motivation
withLinkedState
orwithResource
.Change to
STATE_SOURCE
Given:
Before
Now
Breaking Changes
1. Different object reference
The returned object from
signalState()
orgetState()
no longer keeps the same object identity:Before:
Now:
2. No signal change on empty patch
Empty patches no longer emit updates, since no signal is mutated:
Before:
Now:
3. No wrapping of top-level
WritableSignal
sBefore:
Now:
4.:
patchState
no longer supportsRecord
as root stateUsing a
Record
as the root state is no longer supported bypatchState
.Before:
After:
If dynamic keys are needed, consider managing them inside a nested signal instead.
Further Changes
signalStoreFeature
updated due to changes inWritableStateSource
patchState
now usesNoInfer
onupdaters
to prevent incorrect type inference when chainingPR 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?
Closes #
What is the new behavior?
Does this PR introduce a breaking change?
Other information