8000 feat(signals): allow user-defined signals in `withState` and `signalState` by splitting `STATE_SOURCE` by rainerhahnekamp · Pull Request #4795 · ngrx/platform · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
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

Uh oh!

There was an error while loading. Please reload this page.

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

@markostanimirovic
Copy link
Member

@rainerhahnekamp It's also necessary to write BREAKING CHANGES: ... in a plain text form 8000 at 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.

8000
@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
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
@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>
@rainerhahnekamp rainerhahnekamp mentioned this pull request Jun 16, 2025
3 tasks
Copy link
Member
@markostanimirovic markostanimirovic left a 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.

Comment on lines +1 to +11
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';
Copy link
Member

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', () => {
Copy link
Member

Choose a reason for hiding this comment

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

nit: for consistency

Suggested change
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', () => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
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', () => {
Copy link
Member

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:

Suggested change
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', () => {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
it('allows mixed writable Signal Types', () => {
it('allows mixing signals and plain values', () => {

Comment on lines +86 to +87
'@ngrx/signals: Skipping update for unknown property in state source.',
`Property: ${String(key)}`
Copy link
Member

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:

Suggested change
'@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.',

Comment on lines +94 to +97
if (currentState[signalKey] === newState[signalKey]) {
continue;
}

Copy link
Member

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

Comment on lines +44 to +45
signal: unknown
): signal is WritableSignal<unknown> {
Copy link
Member

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:

Suggested change
signal: unknown
): signal is WritableSignal<unknown> {
value: unknown
): value is WritableSignal<unknown> {

Comment on lines +57 to +70
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`.

Copy link
Member

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.

@markostanimirovic markostanimirovic changed the title feat(signals)!: allow user-defined signals in withState and signalState by splitting STATE_SOURCE feat(signals): allow user-defined signals in withState and signalState by splitting STATE_SOURCE Jun 16, 2025
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
0