Skip to content

Commit 7b04ea5

Browse files
feat(signals): add withLinkedState
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.
1 parent 3b86461 commit 7b04ea5

File tree

5 files changed

+698
-0
lines changed

5 files changed

+698
-0
lines changed

modules/signals/spec/state-source.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,27 @@ describe('StateSource', () => {
293293
TestBed.flushEffects();
294294
expect(executionCount).toBe(2);
295295
});
296+
297+
it('does not support a dynamic type as state', () => {
298+
const Store = signalStore(
299+
{ providedIn: 'root' },
300+
withState<Record<number, number>>({}),
301+
withMethods((store) => ({
302+
addNumber(num: number): void {
303+
patchState(store, {
304+
[num]: num,
305+
});
306+
},
307+
}))
308+
);
309+
const store = TestBed.inject(Store);
310+
311+
store.addNumber(1);
312+
store.addNumber(2);
313+
store.addNumber(3);
314+
315+
expect(getState(store)).toEqual({});
316+
});
296317
});
297318

298319
it('does not support a dynamic type as state', () => {
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { expecter } from 'ts-snippet';
2+
import { compilerOptions } from './helpers';
3+
4+
describe('patchState', () => {
5+
const expectSnippet = expecter(
6+
(code) => `
7+
import { computed, inject, linkedSignal, Signal, signal } from '@angular/core';
8+
import {
9+
patchState,
10+
signalStore,
11+
withState,
12+
withLinkedState,
13+
withMethods
14+
} from '@ngrx/signals';
15+
16+
${code}
17+
`,
18+
compilerOptions()
19+
);
20+
21+
it('does not have access to methods', () => {
22+
const snippet = `
23+
signalStore(
24+
withMethods(() => ({
25+
foo: () => 'bar',
26+
})),
27+
withLinkedState(({ foo }) => ({ value: foo() }))
28+
);
29+
`;
30+
31+
expectSnippet(snippet).toFail(/Property 'foo' does not exist on type '{}'/);
32+
});
33+
34+
it('does not have access to STATE_SOURCE', () => {
35+
const snippet = `
36+
signalStore(
37+
withState({ foo: 'bar' }),
38+
withLinkedState((store) => {
39+
patchState(store, { foo: 'baz' });
40+
return { bar: 'foo' };
41+
})
42+
)
43+
`;
44+
45+
expectSnippet(snippet).toFail(
46+
/is not assignable to parameter of type 'WritableStateSource<object>'./
47+
);
48+
});
49+
50+
it('cannot return a primitive value', () => {
51+
const snippet = `
52+
signalStore(
53+
withLinkedState(() => ({ foo: 'bar' }))
54+
)
55+
`;
56+
57+
expectSnippet(snippet).toFail(
58+
/Type 'string' is not assignable to type 'WritableSignal<unknown> | (() => unknown)'./
59+
);
60+
});
61+
62+
it('resolves to a normal state signal with automatic linkedSignal', () => {
63+
const snippet = `
64+
const UserStore = signalStore(
65+
{ providedIn: 'root' },
66+
withLinkedState(() => ({ firstname: () => 'John', lastname: () => 'Doe' }))
67+
);
68+
69+
const userStore = new UserStore();
70+
71+
const firstname = userStore.firstname;
72+
const lastname = userStore.lastname;
73+
`;
74+
75+
expectSnippet(snippet).toSucceed();
76+
77+
expectSnippet(snippet).toInfer('firstname', 'Signal<string>');
78+
expectSnippet(snippet).toInfer('lastname', 'Signal<string>');
79+
});
80+
81+
it('resolves to a normal state signal with manual linkedSignal', () => {
82+
const snippet = `
83+
const UserStore = signalStore(
84+
{ providedIn: 'root' },
85+
withLinkedState(() => ({
86+
firstname: linkedSignal(() => 'John'),
87+
lastname: linkedSignal(() => 'Doe')
88+
}))
89+
);
90+
91+
const userStore = new UserStore();
92+
93+
const firstname = userStore.firstname;
94+
const lastname = userStore.lastname;
95+
`;
96+
97+
expectSnippet(snippet).toSucceed();
98+
99+
expectSnippet(snippet).toInfer('firstname', 'Signal<string>');
100+
expectSnippet(snippet).toInfer('lastname', 'Signal<string>');
101+
});
102+
103+
it('should set stateSignals as DeepSignal for automatic linkedSignal', () => {
104+
const snippet = `
105+
const UserStore = signalStore(
106+
{ providedIn: 'root' },
107+
withLinkedState(() => ({
108+
user: () => ({ id: 1, name: 'John Doe' }),
109+
location: () => ({ city: 'Berlin', country: 'Germany' }),
110+
}))
111+
);
112+
113+
const userStore = new UserStore();
114+
115+
const location = userStore.location;
116+
const user = userStore.user;
117+
`;
118+
119+
expectSnippet(snippet).toSucceed();
120+
121+
expectSnippet(snippet).toInfer(
122+
'location',
123+
'DeepSignal<{ city: string; country: string; }>'
124+
);
125+
expectSnippet(snippet).toInfer(
126+
'user',
127+
'DeepSignal<{ id: number; name: string; }>'
128+
);
129+
});
130+
131+
it('should set stateSignals as DeepSignal for manual linkedSignal', () => {
132+
const snippet = `
133+
const UserStore = signalStore(
134+
{ providedIn: 'root' },
135+
withLinkedState(() => ({
136+
user: linkedSignal(() => ({ id: 1, name: 'John Doe' })),
137+
location: linkedSignal(() => ({ city: 'Berlin', country: 'Germany' })),
138+
}))
139+
);
140+
141+
const userStore = new UserStore();
142+
143+
const location = userStore.location;
144+
const user = userStore.user;
145+
`;
146+
147+
expectSnippet(snippet).toSucceed();
148+
149+
expectSnippet(snippet).toInfer(
150+
'location',
151+
'DeepSignal<{ city: string; country: string; }>'
152+
);
153+
expectSnippet(snippet).toInfer(
154+
'user',
155+
'DeepSignal<{ id: number; name: string; }>'
156+
);
157+
});
158+
});

0 commit comments

Comments
 (0)