Skip to content

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

Open
wants to merge 14 commits into
base: main
Choose a base branch
from

Conversation

rainerhahnekamp
Copy link
Contributor

@rainerhahnekamp rainerhahnekamp commented May 25, 2025

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 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:

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

  • signalStoreFeature updated due to changes in WritableStateSource
  • patchState now uses NoInfer on updaters to prevent incorrect type inference when chaining

PR Checklist

Please check if your PR fulfills the following requirements:

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

Copy link

netlify bot commented May 25, 2025

Deploy Preview for ngrx-io canceled.

Built without sensitive environment variables

Name Link
🔨 Latest commit 8ee9d11
🔍 Latest deploy log https://app.netlify.com/projects/ngrx-io/deploys/684c77822d044d0008a1c21b

Copy link

netlify bot commented May 25, 2025

Deploy Preview for ngrx-site-v19 ready!

Name Link
🔨 Latest commit 8ee9d11
🔍 Latest deploy log https://app.netlify.com/projects/ngrx-site-v19/deploys/684c77827bdf870008f8172b
😎 Deploy Preview https://deploy-preview-4795--ngrx-site-v19.netlify.app
📱 Preview on mobile
Toggle QR Code...

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 rainerhahnekamp force-pushed the signals/feat/user-defined-signals-in-state branch 2 times, most recently from 3ee3f3b to 4f6c28b Compare May 26, 2025 00:07
@rainerhahnekamp rainerhahnekamp marked this pull request as ready for review May 26, 2025 00:08
@rainerhahnekamp rainerhahnekamp changed the title feat!: allow user-defined signals in withState by splitting STATE_SOURCE feat(signals)!: allow user-defined signals in withState by splitting STATE_SOURCE May 26, 2025
@rainerhahnekamp rainerhahnekamp force-pushed the signals/feat/user-defined-signals-in-state branch from 4f6c28b to 5d23f0c Compare May 26, 2025 00:10
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> {
Copy link
Member

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?

Suggested change
): SignalState<State> {
): SignalState<{ [K in keyof State]: State[K] extends WritableSignal<infer V> ? V : State[K] }> {

Copy link
Contributor Author

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 });

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good to me 👍

Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

const stateSource = signal(initialState as State);
const signalState = toDeepSignal(stateSource.asReadonly());
const stateKeys = Reflect.ownKeys(initialState);
const stateAsRecord = initialState as Record<string | symbol, unknown>;
Copy link
Member

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.

Copy link
Contributor Author

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.

const stateKeys = Reflect.ownKeys(initialState);
const stateAsRecord = initialState as Record<string | symbol, unknown>;

// define STATE_SOURCE property
Copy link
Member

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines +372 to +378
...args:
| [Partial<SignalStoreFeatureResult>, ...SignalStoreFeature[]]
| SignalStoreFeature[]
): SignalStoreFeature<EmptyFeatureResult, EmptyFeatureResult> {
const features = (
typeof args[0] === 'function' ? args : args.slice(1)
) as SignalStoreFeature[];
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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

Copy link
Member

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> {

Comment on lines +59 to +68
| Partial<Prettify<NoInfer<State>>>
| PartialStateUpdater<Prettify<NoInfer<State>>>
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

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

Copy link
Member

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.

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
Copy link
Member

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

Copy link
Contributor Author

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.

Copy link
Member

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.

Comment on lines 13 to 17
type StripWritableSignals<State extends object> = {
[Property in keyof State]: State[Property] extends WritableSignal<infer Type>
? Type
: State[Property];
};
Copy link
Member

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

Suggested change
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];
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done renaming

);
return { ...acc, [key]: toDeepSignal(sliceSignal) };
}, {} as SignalsDictionary);
const stateAsRecord = state as Record<string | symbol, unknown>;
Copy link
Member

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.

Comment on lines 50 to 48
const signalValue = stateAsRecord[key];
stateSource[key] = isWritableSignal(signalValue)
? signalValue
: signal(signalValue);
stateSignals[key] = toDeepSignal(stateSource[key]);
Copy link
Member

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:

Suggested change
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);

Copy link
Contributor Author

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.

Copy link
Member

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 👍

@markostanimirovic
Copy link
Member

@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: #4584

@rainerhahnekamp
Copy link
Contributor Author

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.

@rainerhahnekamp rainerhahnekamp force-pushed the signals/feat/user-defined-signals-in-state branch from 1057bd7 to be6190f Compare June 1, 2025 16:43
@rainerhahnekamp
Copy link
Contributor Author

@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?

@markostanimirovic
Copy link
Member

@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.

@rainerhahnekamp rainerhahnekamp force-pushed the signals/feat/user-defined-signals-in-state branch 2 times, most recently from 574c406 to 8fd5c51 Compare June 4, 2025 08:54
@rainerhahnekamp rainerhahnekamp changed the title feat(signals)!: allow user-defined signals in withState by splitting STATE_SOURCE feat(signals)!: allow user-defined signals in withState and signalState by splitting STATE_SOURCE Jun 4, 2025
@rainerhahnekamp rainerhahnekamp force-pushed the signals/feat/user-defined-signals-in-state branch from 26c18a2 to 5f159a1 Compare June 4, 2025 09:09
@rainerhahnekamp
Copy link
Contributor Author

@markostanimirovic, @timdeschryver

  • signalState now also support user-defined writable Signals
  • I've updated the docs, by adding an example in signalState for both old and new website.

I will push the PR for withLinkedState later today.

@rainerhahnekamp rainerhahnekamp force-pushed the signals/feat/user-defined-signals-in-state branch from ec120da to 3b86461 Compare June 7, 2025 22:08
…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
@rainerhahnekamp rainerhahnekamp force-pushed the signals/feat/user-defined-signals-in-state branch from 3b86461 to ee3d751 Compare June 7, 2025 23:43
Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants