Skip to content

Commit 345ab4f

Browse files
feat(signals): add withFeature (#4739)
Closes #4678
1 parent 92fd7c3 commit 345ab4f

File tree

4 files changed

+273
-0
lines changed

4 files changed

+273
-0
lines changed
+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import {
2+
computed,
3+
inject,
4+
Injectable,
5+
ResourceStatus,
6+
Signal,
7+
} from '@angular/core';
8+
import { TestBed } from '@angular/core/testing';
9+
import { tapResponse } from '@ngrx/operators';
10+
import { lastValueFrom, Observable, of, pipe, switchMap, tap } from 'rxjs';
11+
import { describe, expect, it } from 'vitest';
12+
import { EntityState, setAllEntities, withEntities } from '../entities';
13+
import { rxMethod } from '../rxjs-interop';
14+
import {
15+
getState,
16+
patchState,
17+
signalStore,
18+
signalStoreFeature,
19+
type,
20+
withComputed,
21+
withFeature,
22+
withHooks,
23+
withMethods,
24+
withProps,
25+
withState,
26+
} from '../src';
27+
28+
type User = {
29+
id: number;
30+
name: string;
31+
};
32+
33+
describe('withFeature', () => {
34+
it('provides methods', async () => {
35+
function withMyEntity<Entity>(loadMethod: (id: number) => Promise<Entity>) {
36+
return signalStoreFeature(
37+
withState({
38+
currentId: 1 as number | undefined,
39+
entity: undefined as undefined | Entity,
40+
}),
41+
withMethods((store) => ({
42+
async load(id: number) {
43+
const entity = await loadMethod(1);
44+
patchState(store, { entity, currentId: id });
45+
},
46+
}))
47+
);
48+
}
49+
50+
const UserStore = signalStore(
51+
{ providedIn: 'root' },
52+
withMethods(() => ({
53+
findById(id: number) {
54+
return of({ id: 1, name: 'Konrad' });
55+
},
56+
})),
57+
withFeature((store) => {
58+
const loader = (id: number) => lastValueFrom(store.findById(id));
59+
return withMyEntity<User>(loader);
60+
})
61+
);
62+
63+
const userStore = TestBed.inject(UserStore);
64+
await userStore.load(1);
65+
expect(getState(userStore)).toEqual({
66+
currentId: 1,
67+
entity: { id: 1, name: 'Konrad' },
68+
});
69+
});
70+
71+
it('provides state signals', async () => {
72+
const withDouble = (n: Signal<number>) =>
73+
signalStoreFeature(
74+
withComputed((state) => ({ double: computed(() => n() * 2) }))
75+
);
76+
77+
const Store = signalStore(
78+
{ providedIn: 'root' },
79+
withState({ counter: 1 }),
80+
withMethods((store) => ({
81+
increaseCounter() {
82+
patchState(store, ({ counter }) => ({ counter: counter + 1 }));
83+
},
84+
})),
85+
withFeature(({ counter }) => withDouble(counter))
86+
);
87+
88+
const store = TestBed.inject(Store);
89+
90+
expect(store.double()).toBe(2);
91+
store.increaseCounter();
92+
expect(store.double()).toBe(4);
93+
});
94+
95+
it('provides properties', () => {
96+
@Injectable({ providedIn: 'root' })
97+
class Config {
98+
baseUrl = 'https://www.ngrx.io';
99+
}
100+
const withUrlizer = (baseUrl: string) =>
101+
signalStoreFeature(
102+
withMethods(() => ({
103+
createUrl: (path: string) =>
104+
`${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`,
105+
}))
106+
);
107+
108+
const Store = signalStore(
109+
{ providedIn: 'root' },
110+
withProps(() => ({
111+
_config: inject(Config),
112+
})),
113+
withFeature((store) => withUrlizer(store._config.baseUrl))
114+
);
115+
116+
const store = TestBed.inject(Store);
117+
expect(store.createUrl('docs')).toBe('https://www.ngrx.io/docs');
118+
});
119+
120+
it('can be cominbed with inputs', () => {
121+
function withLoadEntities<Entity extends { id: number }, Filter>(config: {
122+
filter: Signal<Filter>;
123+
loader: (filter: Filter) => Observable<Entity[]>;
124+
}) {
125+
return signalStoreFeature(
126+
type<{ state: EntityState<Entity> & { status: ResourceStatus } }>(),
127+
withMethods((store) => ({
128+
_loadEntities: rxMethod<Filter>(
129+
pipe(
130+
tap(() => patchState(store, { status: ResourceStatus.Loading })),
131+
switchMap((filter) =>
132+
config.loader(filter).pipe(
133+
tapResponse({
134+
next: (entities) =>
135+
patchState(
136+
store,
137+
{ status: ResourceStatus.Resolved },
138+
setAllEntities(entities)
139+
),
140+
error: () =>
141+
patchState(store, { status: ResourceStatus.Error }),
142+
})
143+
)
144+
)
145+
)
146+
),
147+
})),
148+
withHooks({
149+
onInit: ({ _loadEntities }) => _loadEntities(config.filter),
150+
})
151+
);
152+
}
153+
154+
const Store = signalStore(
155+
{ providedIn: 'root' },
156+
withEntities<User>(),
157+
withState({ filter: { name: '' }, status: ResourceStatus.Idle }),
158+
withMethods((store) => ({
159+
setFilter(name: string) {
160+
patchState(store, { filter: { name } });
161+
},
162+
_load(filters: { name: string }) {
163+
return of(
164+
[{ id: 1, name: 'Konrad' }].filter((person) =>
165+
person.name.startsWith(filters.name)
166+
)
167+
);
168+
},
169+
})),
170+
withFeature((store) =>
171+
withLoadEntities({ filter: store.filter, loader: store._load })
172+
)
173+
);
174+
175+
const store = TestBed.inject(Store);
176+
177+
expect(store.entities()).toEqual([]);
178+
store.setFilter('K');
179+
TestBed.flushEffects();
180+
expect(store.entities()).toEqual([{ id: 1, name: 'Konrad' }]);
181+
store.setFilter('Sabine');
182+
TestBed.flushEffects();
183+
expect(store.entities()).toEqual([]);
184+
});
185+
});

modules/signals/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
export { Prettify } from './ts-helpers';
2424

2525
export { withComputed } from './with-computed';
26+
export { withFeature } from './with-feature';
2627
export { withHooks } from './with-hooks';
2728
export { withMethods } from './with-methods';
2829
export { withProps } from './with-props';

modules/signals/src/with-feature.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
SignalStoreFeature,
3+
SignalStoreFeatureResult,
4+
StateSignals,
5+
} from './signal-store-models';
6+
import { Prettify } from './ts-helpers';
7+
8+
/**
9+
* @description
10+
* Allows passing properties, methods, or signals from a SignalStore
11+
* to a feature.
12+
*
13+
* @usageNotes
14+
* ```typescript
15+
* signalStore(
16+
* withMethods((store) => ({
17+
* load(id: number): Observable<Entity> {
18+
* return of({ id, name: 'John' });
19+
* },
20+
* })),
21+
* withFeature(
22+
* // 👇 has full access to the store
23+
* (store) => withEntityLoader((id) => firstValueFrom(store.load(id)))
24+
* )
25+
* );
26+
* ```
27+
*
28+
* @param featureFactory function returning the actual feature
29+
*/
30+
export function withFeature<
31+
Input extends SignalStoreFeatureResult,
32+
Output extends SignalStoreFeatureResult
33+
>(
34+
featureFactory: (
35+
store: Prettify<
36+
StateSignals<Input['state']> & Input['props'] & Input['methods']
37+
>
38+
) => SignalStoreFeature<Input, Output>
39+
): SignalStoreFeature<Input, Output> {
40+
return (store) => {
41+
const storeForFactory = {
42+
...store['stateSignals'],
43+
...store['props'],
44+
...store['methods'],
45+
};
46+
47+
return featureFactory(storeForFactory)(store);
48+
};
49+
}

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

+38
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,41 @@ const Store = signalStore(
318318
withW()
319319
); // ✅ works as expected
320320
```
321+
322+
For more complicated use cases, `withFeature` offers an alternative approach.
323+
324+
## Connecting a Custom Feature with the Store
325+
326+
The `withFeature` function allows passing properties, methods, or signals from a SignalStore to a custom feature.
327+
328+
This is an alternative to the input approach above and allows more flexibility:
329+
330+
<code-example header="loader.store.ts">
331+
332+
import { computed, Signal } from '@angular/core';
333+
import { patchState, signalStore, signalStoreFeature, withComputed, withFeature, withMethods, withState } from '@ngrx/signals';
334+
import { withEntities } from '@ngrx/signals/entities';
335+
336+
export function withBooksFilter(books: Signal&lt;Book[]&gt;) {
337+
return signalStoreFeature(
338+
withState({ query: '' }),
339+
withComputed(({ query }) => ({
340+
filteredBooks: computed(() =>
341+
books().filter((b) => b.name.includes(query()))
342+
),
343+
})),
344+
withMethods((store) => ({
345+
setQuery(query: string): void {
346+
patchState(store, { query });
347+
},
348+
})),
349+
)};
350+
351+
export const BooksStore = signalStore(
352+
withEntities&lt;Book&gt;(),
353+
withFeature(({ entities }) =>
354+
withBooksFilter(entities)
355+
),
356+
);
357+
358+
</code-example>

0 commit comments

Comments
 (0)