8000 feat(signals): add `withLinkedState()` by rainerhahnekamp · Pull Request #4818 · ngrx/platform · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat(signals): add withLinkedState() #4818

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 13 commits into
base: main
Choose a base branch
from

Conversation

rainerhahnekamp
Copy link
Contributor
@rainerhahnekamp rainerhahnekamp commented Jun 4, 2025

This is a non-breaking feature to support linkedSignal.

This branch is based on #4795 which has to be merged first.

Please read the comment in #4871


withLinkedState generates and adds the properties of a linkedSignal to the store's state.

Usage Notes:

const UserStore = signalStore(
  withState({ options: [1, 2, 3] }),
  withLinkedState(({ options }) => ({ selectOption: options()[0] ?? undefined }))
);

The resulting state is of type { options: number[], selectOption: number | undefined }.
Whenever the options signal changes, the selectOption will automatically update.

For advanced use cases, linkedSignal can be called within withLinkedState:

const UserStore = signalStore(
  withState({ id: 1 }),
  withLinkedState(({ id }) => linkedSignal({
    source: id,
    computation: () => ({ firstname: '', lastname: '' })
  }))
)

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 #4871

What is the new behavior?

Does this PR introduce a breaking change?

[ ] Yes
[x] No

Other information

Copy link
netlify bot commented Jun 4, 2025

Deploy Preview for ngrx-io ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 7b04ea5
🔍 Latest deploy log https://app.netlify.com/projects/ngrx-io/deploys/6844b9253e2b3a0008153b8f
😎 Deploy Preview https://deploy-preview-4818--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
8000
netlify bot commented Jun 4, 2025

Deploy Preview for ngrx-site-v19 ready!

Name Link
🔨 Latest commit 7b04ea5
🔍 Latest deploy log https://app.netlify.com/projects/ngrx-site-v19/deploys/6844b92560f8490008b17659
😎 Deploy Preview https://deploy-preview-4818--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 and others added 13 commits June 8, 2025 00: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: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com>
Generates and adds the properties of a `linkedSignal`
to the store's state.

## Usage Notes:

```typescript
const UserStore = signalStore(
  withState({ options: [1, 2, 3] }),
  withLinkedState(({ options }) => ({ selectOption: () => options()[0] ?? undefined }))
);
```

The resulting state is of type `{ options: number[], selectOption: number | undefined }`.
Whenever the `options` signal changes, the `selectOption` will automatically update.

For advanced use cases, `linkedSignal` can be called within `withLinkedState`:

```typescript
const UserStore = signalStore(
  withState({ id: 1 }),
  withLinkedState(({ id }) => ({
    user: linkedSignal({
      source: id,
      computation: () => ({ firstname: '', lastname: '' })
    })
  }))
)
```

## Implementation Notes

We do not want to encourage wrapping larger parts of the state into a `linkedSignal`.
This decision is primarily driven by performance concerns.

When the entire state is bound to a single signal, any change - regardless of which part -
- is tracked through that one signal.
This means all direct consumers are notified, even if only a small slice of the state actually changed.

Instead, each root property of the state should be a Signal on its own. That's why
the design of `withLinkedState` cannot represent be the whole state.
@rainerhahnekamp rainerhahnekamp force-pushed the signals/feat/with-linked-state branch from 7bc804e to 7b04ea5 Compare June 7, 2025 22:11
@rainerhahnekamp rainerhahnekamp marked this pull request as ready for review June 16, 2025 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant
0