Skip to content

Commit e4c3021

Browse files
feat(signals): add watchState function (#4442)
Closes #4416
1 parent ca8d3a1 commit e4c3021

File tree

6 files changed

+401
-26
lines changed

6 files changed

+401
-26
lines changed

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

+160-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
import { effect } from '@angular/core';
1+
import {
2+
createEnvironmentInjector,
3+
effect,
4+
EnvironmentInjector,
5+
Injectable,
6+
} from '@angular/core';
27
import { TestBed } from '@angular/core/testing';
38
import {
49
getState,
510
patchState,
611
signalState,
712
signalStore,
13+
watchState,
14+
withHooks,
815
withMethods,
916
withState,
1017
} from '../src';
1118
import { STATE_SOURCE } from '../src/state-source';
19+
import { createLocalService } from './helpers';
1220

1321
describe('StateSource', () => {
1422
const initialState = {
@@ -153,4 +161,155 @@ describe('StateSource', () => {
153161
});
154162
});
155163
});
164+
165+
describe('watchState', () => {
166+
describe('with signalState', () => {
167+
it('watches state changes', () => {
168+
const state = signalState({ count: 0 });
169+
const stateHistory: number[] = [];
170+
171+
TestBed.runInInjectionContext(() => {
172+
watchState(state, (state) => stateHistory.push(state.count));
173+
});
174+
175+
patchState(state, { count: 1 });
176+
patchState(state, { count: 2 });
177+
patchState(state, { count: 3 });
178+
179+
expect(stateHistory).toEqual([0, 1, 2, 3]);
180+
});
181+
182+
it('stops watching on injector destroy', () => {
183+
const stateHistory: number[] = [];
184+
const state = signalState({ count: 0 });
185+
186+
@Injectable()
187+
class TestService {
188+
constructor() {
189+
watchState(state, (state) => stateHistory.push(state.count));
190+
}
191+
}
192+
193+
const { destroy } = createLocalService(TestService);
194+
195+
patchState(state, { count: 1 });
196+
197+
destroy();
198+
199+
patchState(state, { count: 2 });
200+
patchState(state, { count: 3 });
201+
202+
expect(stateHistory).toEqual([0, 1]);
203+
});
204+
205+
it('stops watching on manual destroy', () => {
206+
const state = signalState({ count: 0 });
207+
const stateHistory: number[] = [];
208+
209+
const { destroy } = TestBed.runInInjectionContext(() =>
210+
watchState(state, (state) => stateHistory.push(state.count))
211+
);
212+
213+
patchState(state, { count: 1 });
214+
patchState(state, { count: 2 });
215+
216+
destroy();
217+
218+
patchState(state, { count: 3 });
219+
220+
expect(stateHistory).toEqual([0, 1, 2]);
221+
});
222+
223+
it('stops watching on provided injector destroy', () => {
224+
const injector1 = createEnvironmentInjector(
225+
[],
226+
TestBed.inject(EnvironmentInjector)
227+
);
228+
const injector2 = createEnvironmentInjector(
229+
[],
230+
TestBed.inject(EnvironmentInjector)
231+
);
232+
const state = signalState({ count: 0 });
233+
const stateHistory1: number[] = [];
234+
const stateHistory2: number[] = [];
235+
236+
watchState(state, (state) => stateHistory1.push(state.count), {
237+
injector: injector1,
238+
});
239+
watchState(state, (state) => stateHistory2.push(state.count), {
240+
injector: injector2,
241+
});
242+
243+
patchState(state, { count: 1 });
244+
patchState(state, { count: 2 });
245+
246+
injector1.destroy();
247+
248+
patchState(state, { count: 3 });
249+
250+
injector2.destroy();
251+
252+
patchState(state, { count: 4 });
253+
254+
expect(stateHistory1).toEqual([0, 1, 2]);
255+
expect(stateHistory2).toEqual([0, 1, 2, 3]);
256+
});
257+
258+
it('throws an error when called out of injection context', () => {
259+
expect(() => watchState(signalState({}), () => {})).toThrow(
260+
/NG0203: watchState\(\) can only be used within an injection context/
261+
);
262+
});
263+
});
264+
265+
describe('with signalStore', () => {
266+
it('watches state changes when used within the store', () => {
267+
const stateHistory: number[] = [];
268+
const CounterStore = signalStore(
269+
withState({ count: 0 }),
270+
withHooks({
271+
onInit(store) {
272+
patchState(store, { count: 1 });
273+
274+
watchState(store, (state) => stateHistory.push(state.count));
275+
276+
patchState(store, { count: 2 });
277+
patchState(store, { count: 3 });
278+
},
279+
})
280+
);
281+
282+
TestBed.configureTestingModule({ providers: [CounterStore] });
283+
TestBed.inject(CounterStore);
284+
285+
expect(stateHistory).toEqual([1, 2, 3]);
286+
});
287+
288+
it('watches state changes when used outside of store', () => {
289+
const stateHistory: number[] = [];
290+
const CounterStore = signalStore(
291+
withState({ count: 0 }),
292+
withMethods((store) => ({
293+
increment(): void {
294+
patchState(store, (state) => ({ count: state.count + 1 }));
295+
},
296+
}))
297+
);
298+
299+
TestBed.configureTestingModule({ providers: [CounterStore] });
300+
const store = TestBed.inject(CounterStore);
301+
const injector = TestBed.inject(EnvironmentInjector);
302+
303+
watchState(store, (state) => stateHistory.push(state.count), {
304+
injector,
305+
});
306+
307+
store.increment();
308+
store.increment();
309+
store.increment();
310+
311+
expect(stateHistory).toEqual([0, 1, 2, 3]);
312+
});
313+
});
314+
});
156315
});

modules/signals/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
PartialStateUpdater,
1414
patchState,
1515
StateSource,
16+
watchState,
1617
WritableStateSource,
1718
} from './state-source';
1819
export { Prettify } from './ts-helpers';

modules/signals/src/state-source.ts

+78-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1-
import { Signal, WritableSignal } from '@angular/core';
1+
import {
2+
assertInInjectionContext,
3+
DestroyRef,
4+
inject,
5+
Injector,
6+
Signal,
7+
untracked,
8+
WritableSignal,
9+
} from '@angular/core';
10+
import { SIGNAL } from '@angular/core/primitives/signals';
211
import { Prettify } from './ts-helpers';
312

13+
const STATE_WATCHERS = new WeakMap<object, Array<StateWatcher<any>>>();
14+
415
export const STATE_SOURCE = Symbol('STATE_SOURCE');
516

617
export type WritableStateSource<State extends object> = {
@@ -15,6 +26,10 @@ export type PartialStateUpdater<State extends object> = (
1526
state: State
1627
) => Partial<State>;
1728

29+
export type StateWatcher<State extends object> = (
30+
state: NoInfer<State>
31+
) => void;
32+
1833
export function patchState<State extends object>(
1934
stateSource: WritableStateSource<State>,
2035
...updaters: Array<
@@ -30,10 +45,72 @@ export function patchState<State extends object>(
3045
currentState
3146
)
3247
);
48+
49+
notifyWatchers(stateSource);
3350
}
3451

3552
export function getState<State extends object>(
3653
stateSource: StateSource<State>
3754
): State {
3855
return stateSource[STATE_SOURCE]();
3956
}
57+
58+
export function watchState<State extends object>(
59+
stateSource: StateSource<State>,
60+
watcher: StateWatcher<State>,
61+
config?: { injector?: Injector }
62+
): { destroy(): void } {
63+
if (!config?.injector) {
64+
assertInInjectionContext(watchState);
65+
}
66+
67+
const injector = config?.injector ?? inject(Injector);
68+
const destroyRef = injector.get(DestroyRef);
69+
70+
addWatcher(stateSource, watcher);
71+
watcher(getState(stateSource));
72+
73+
const destroy = () => removeWatcher(stateSource, watcher);
74+
destroyRef.onDestroy(destroy);
75+
76+
return { destroy };
77+
}
78+
79+
function getWatchers<State extends object>(
80+
stateSource: StateSource<State>
81+
): Array<StateWatcher<State>> {
82+
return STATE_WATCHERS.get(stateSource[STATE_SOURCE][SIGNAL] as object) || [];
83+
}
84+
85+
function notifyWatchers<State extends object>(
86+
stateSource: StateSource<State>
87+
): void {
88+
const watchers = getWatchers(stateSource);
89+
90+
for (const watcher of watchers) {
91+
const state = untracked(() => getState(stateSource));
92+
watcher(state);
93+
}
94+
}
95+
96+
function addWatcher<State extends object>(
97+
stateSource: StateSource<State>,
98+
watcher: StateWatcher<State>
99+
): void {
100+
const watchers = getWatchers(stateSource);
101+
STATE_WATCHERS.set(stateSource[STATE_SOURCE][SIGNAL] as object, [
102+
...watchers,
103+
watcher,
104+
]);
105+
}
106+
107+
function removeWatcher<State extends object>(
108+
stateSource: StateSource<State>,
109+
watcher: StateWatcher<State>
110+
): void {
111+
const watchers = getWatchers(stateSource);
112+
STATE_WATCHERS.set(
113+
stateSource[STATE_SOURCE][SIGNAL] as object,
114+
watchers.filter((w) => w !== watcher)
115+
);
116+
}

projects/ngrx.io/content/guide/signals/signal-store/index.md

-24
Original file line numberDiff line numberDiff line change
@@ -141,30 +141,6 @@ export class BooksComponent {
141141

142142
</code-example>
143143

144-
The `@ngrx/signals` package also offers the `getState` function to get the current state value of the SignalStore.
145-
When used within the reactive context, state changes are automatically tracked.
146-
147-
<code-example header="books.component.ts">
148-
149-
import { Component, effect, inject } from '@angular/core';
150-
import { getState } from '@ngrx/signals';
151-
import { BooksStore } from './books.store';
152-
153-
@Component({ /* ... */ })
154-
export class BooksComponent {
155-
readonly store = inject(BooksStore);
156-
157-
constructor() {
158-
effect(() => {
159-
// 👇 The effect will be re-executed whenever the state changes.
160-
const state = getState(this.store);
161-
console.log('books state changed', state);
162-
});
163-
}
164-
}
165-
166-
</code-example>
167-
168144
## Defining Computed Signals
169145

170146
Computed signals can be added to the store using the `withComputed` feature.

0 commit comments

Comments
 (0)