-
-
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
import { DeepSignal, toDeepSignal } from './deep-signal'; | ||
import { SignalsDictionary } from './signal-store-models'; | ||
import { STATE_SOURCE, WritableStateSource } from './state-source'; | ||
|
||
export type SignalState<State extends object> = DeepSignal<State> & | ||
WritableStateSource<State>; | ||
|
||
export function signalState<State extends object>( | ||
initialState: State | ||
): SignalState<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.
Should we enable the same functionality for the signalState
?
): SignalState<State> { | |
): SignalState<{ [K in keyof State]: State[K] extends WritableSignal<infer V> ? V : State[K] }> { |
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.
@timdeschryver, @markostanimirovic. Your call. I'm happy with extending signalState
or keeping it as it is.
Just for clarity. It should be possible to do something like this.
const userSignal = signal({ id: 1, name: 'John' });
const userState = signalState({ user: userSignal });
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.
sounds good to me 👍
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.
For me as well, I will go over this PR tomorrow/wednesday.
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.
Alright, then let me add user-defined Signals to signalState
as well.
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.
Done.
Implementation Detail: I moved the type StateResult
into the SignalState
instead of using it as return type of signalState
. That has the advantage that the type SignalState<State>
can be kept.
modules/signals/src/signal-state.ts
Outdated
const stateSource = signal(initialState as State); | ||
const signalState = toDeepSignal(stateSource.asReadonly()); | ||
const stateKeys = Reflect.ownKeys(initialState); | ||
const stateAsRecord = initialState 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.
If this variable is required only because of the changed type, let's use casting directly on line 19, since it's used only there.
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.
Done for stateAsRecord
. stateKeys
is used three times.
modules/signals/src/signal-state.ts
Outdated
const stateKeys = Reflect.ownKeys(initialState); | ||
const stateAsRecord = initialState as Record<string | symbol, unknown>; | ||
|
||
// define STATE_SOURCE property |
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.
I suggest removing this and other comments below that describe what the code is doing. The implementation looks straightforward, so I don't think there is a need for that.
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.
Done
...args: | ||
| [Partial<SignalStoreFeatureResult>, ...SignalStoreFeature[]] | ||
| SignalStoreFeature[] | ||
): SignalStoreFeature<EmptyFeatureResult, EmptyFeatureResult> { | ||
const features = ( | ||
typeof args[0] === 'function' ? args : args.slice(1) | ||
) as SignalStoreFeature[]; |
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.
why is this change necessary?
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.
With the change for the types, the typings stopped working for signalStoreFeature
(not for signalStore
). I had to apply this change to make it work again.
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.
I'll take a closer look
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 closest I can come up with is the following signature, but this uses the any
type , which isn't ideal.
export function signalStoreFeature(
featureOrInput: SignalStoreFeature | Partial<SignalStoreFeatureResult>,
...restFeatures: SignalStoreFeature<any, SignalStoreFeatureResult>[]
): SignalStoreFeature<EmptyFeatureResult, EmptyFeatureResult> {
| Partial<Prettify<NoInfer<State>>> | ||
| PartialStateUpdater<Prettify<NoInfer<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.
why do we need NoInfer
together with Prettify
here?
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.
Without NoInfer
, a sequence of updaters would not work anymore.
You can try it out by removing the NoInfer
and then navigating to the test in state-source.spec.ts
in line 122.
Not sure if we can drop Prettify
. I didn't because it was already there before.
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.
Thanks for the explanation. I'll take a look
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.
I think using NoInfer
is fine here, otherwise the State
type will be updated based on the updaters types. AFAIK this makes sure the State
type remains intact.
modules/signals/src/state-source.ts
Outdated
const stateKeys = Reflect.ownKeys(stateSource[STATE_SOURCE]); | ||
for (const key of Reflect.ownKeys(newState)) { | ||
if (!stateKeys.includes(key)) { | ||
// TODO: Optional properties which don't exist in the initial state will not be added |
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 display a warning in dev mode for this case
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 have here another breaking change. If we have a state with an optional root property and the initial state skips that one, patchState
will always skip that one.
We could do a check for undefined
in withState
and/or come with an ESLint rule.
I've added some additional tests.
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.
Currently, it's also not 'allowed' to have optional state slices. If a state slice is not provided to the initial state, the state signal is never created.
modules/signals/src/with-state.ts
Outdated
type StripWritableSignals<State extends object> = { | ||
[Property in keyof State]: State[Property] extends WritableSignal<infer Type> | ||
? Type | ||
: State[Property]; | ||
}; |
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.
(1) I'd rename the State
generic to StateInput
and StripWritableSignals
to StateResult
.
(2) Value
is probably a more precise name than Type
here. Anyway, since general/generic names are used here, the readability will be the same IMO if we go with letters only.
(3) An empty line is missing before the function definition
type StripWritableSignals<State extends object> = { | |
[Property in keyof State]: State[Property] extends WritableSignal<infer Type> | |
? Type | |
: State[Property]; | |
}; | |
type StateResult<StateInput extends object> = { | |
[K in keyof StateInput]: StateInput[K] extends WritableSignal<infer V> | |
? V | |
: StateInput[K]; | |
}; |
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.
Done renaming
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.
modules/signals/src/with-state.ts
Outdated
const signalValue = stateAsRecord[key]; | ||
stateSource[key] = isWritableSignal(signalValue) | ||
? signalValue | ||
: signal(signalValue); | ||
stateSignals[key] = toDeepSignal(stateSource[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.
instead of reassigning stateSignals[key]
2 times:
const signalValue = stateAsRecord[key]; | |
stateSource[key] = isWritableSignal(signalValue) | |
? signalValue | |
: signal(signalValue); | |
stateSignals[key] = toDeepSignal(stateSource[key]); | |
const signalOrValue = (state as Record<string | symbol, unknown>)[key]; | |
const sig = isWritableSignal(signalOrValue) ? signalOrValue : signal(signalOrValue); | |
stateSource[key] = toDeepSignal(sig); |
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.
Marko, don't we have to assign them twice? Once for STATE_SOURCE
and once for the state signals? That would be variables stateSource
and stateSignals
.
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.
Oh sorry, my bad. The second assignment is for stateSignals
, not stateSource
. All good here 👍
@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 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` 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>
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