platform icon indicating copy to clipboard operation
platform copied to clipboard

feat(signals)!: allow user-defined signals in `withState` and `signalState` by splitting `STATE_SOURCE`

Open rainerhahnekamp opened this issue 6 months ago • 7 comments

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:

  • [x] The commit message follows our guidelines: https://github.com/ngrx/platform/blob/main/CONTRIBUTING.md#commit
  • [x] Tests for the changes have been added (for bug fixes / features)
  • [] Documentation has been added / updated (for bug fixes / features)

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

rainerhahnekamp avatar May 25 '25 23:05 rainerhahnekamp

Deploy Preview for ngrx-io ready!

Built without sensitive environment variables

Name Link
Latest commit 5b95c9508b979619668eef8ee22fe0ee0a08c045
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.

netlify[bot] avatar May 25 '25 23:05 netlify[bot]

Deploy Preview for ngrx-site-v19 ready!

Name Link
Latest commit 5b95c9508b979619668eef8ee22fe0ee0a08c045
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.

netlify[bot] avatar May 25 '25 23:05 netlify[bot]

@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: https://github.com/ngrx/platform/pull/4584

markostanimirovic avatar May 29 '25 20:05 markostanimirovic

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 avatar May 29 '25 20:05 rainerhahnekamp

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

I see commits from main have been merged. Can I rebase them instead or does GitHub do that automatically meanwhile?

rainerhahnekamp avatar Jun 01 '25 16:06 rainerhahnekamp

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

markostanimirovic avatar Jun 01 '25 17:06 markostanimirovic

@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 avatar Jun 04 '25 09:06 rainerhahnekamp

@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

rainerhahnekamp avatar Jun 24 '25 20:06 rainerhahnekamp