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

Conversation

rainerhahnekamp
Copy link
Contributor

@rainerhahnekamp rainerhahnekamp commented May 25, 2025

BREAKING CHANGES:

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
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 ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 5b95c95
🔍 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...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link

netlify bot commented May 25, 2025

Deploy Preview for ngrx-site-v19 ready!

Name Link
🔨 Latest commit 5b95c95
🔍 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...

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

@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
rainerhahnekamp and others added 10 commits June 23, 2025 23:29
Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com>
@rainerhahnekamp
Copy link
Contributor Author

@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

@markostanimirovic markostanimirovic merged commit 521a2a6 into ngrx:main Jun 29, 2025
14 checks passed
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