Skip to content

Commit aead6f8

Browse files
feat: add withFeatureFactory()
The `withFeatureFactory()` function allows passing properties, methods, or signals from a SignalStore to a feature. It is an advanced feature, primarily targeted for library authors for SignalStore features. Its usage is very simple. It is a function which gets the current store: ```typescript function withSum(a: Signal<number>, b: Signal<number>) { return signalStoreFeature( withComputed(() => ({ sum: computed(() => a() + b()) })) ); } signalStore( withState({ a: 1, b: 2 }), withFeatureFactory((store) => withSum(store.a, store.b)) ); ```
1 parent 8203d33 commit aead6f8

File tree

10 files changed

+425
-3
lines changed

10 files changed

+425
-3
lines changed

apps/demo/e2e/feature-factory.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('feature factory', () => {
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('');
6+
await page.getByRole('link', { name: 'withFeatureFactory' }).click();
7+
});
8+
9+
test(`loads user`, async ({ page }) => {
10+
await expect(page.getByText('Current User: -')).toBeVisible();
11+
await page.getByRole('button', { name: 'Load User' }).click();
12+
await expect(page.getByText('Current User: Konrad')).toBeVisible();
13+
});
14+
});

apps/demo/src/app/app.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
2222
<a mat-list-item routerLink="/reset">withReset</a>
2323
<a mat-list-item routerLink="/immutable-state">withImmutableState</a>
24+
<a mat-list-item routerLink="/feature-factory">withFeatureFactory</a>
2425
</mat-nav-list>
2526
</mat-drawer>
2627
<mat-drawer-content>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Component, inject } from '@angular/core';
2+
import {
3+
patchState,
4+
signalStore,
5+
signalStoreFeature,
6+
withMethods,
7+
withState,
8+
} from '@ngrx/signals';
9+
import { MatButton } from '@angular/material/button';
10+
import { FormsModule } from '@angular/forms';
11+
import { lastValueFrom, of } from 'rxjs';
12+
import { withFeatureFactory } from '@angular-architects/ngrx-toolkit';
13+
14+
type User = {
15+
id: number;
16+
name: string;
17+
};
18+
19+
function withMyEntity<Entity>(loadMethod: (id: number) => Promise<Entity>) {
20+
return signalStoreFeature(
21+
withState({
22+
currentId: 1 as number | undefined,
23+
entity: undefined as undefined | Entity,
24+
}),
25+
withMethods((store) => ({
26+
async load(id: number) {
27+
const entity = await loadMethod(1);
28+
patchState(store, { entity, currentId: id });
29+
},
30+
}))
31+
);
32+
}
33+
34+
const UserStore = signalStore(
35+
{ providedIn: 'root' },
36+
withMethods(() => ({
37+
findById(id: number) {
38+
return of({ id: 1, name: 'Konrad' });
39+
},
40+
})),
41+
withFeatureFactory((store) => {
42+
const loader = (id: number) => lastValueFrom(store.findById(id));
43+
return withMyEntity<User>(loader);
44+
})
45+
);
46+
47+
@Component({
48+
template: `
49+
<h2>
50+
<pre>withFeatureFactory</pre>
51+
</h2>
52+
53+
<button mat-raised-button (click)="loadUser()">Load User</button>
54+
55+
<p>Current User: {{ userStore.entity()?.name || '-' }}</p>
56+
`,
57+
imports: [MatButton, FormsModule],
58+
})
59+
export class FeatureFactoryComponent {
60+
protected readonly userStore = inject(UserStore);
61+
62+
loadUser() {
63+
void this.userStore.load(1);
64+
}
65+
}

apps/demo/src/app/lazy-routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,11 @@ export const lazyRoutes: Route[] = [
4545
(m) => m.ImmutableStateComponent
4646
),
4747
},
48+
{
49+
path: 'feature-factory',
50+
loadComponent: () =>
51+
import('./feature-factory/feature-factory.component').then(
52+
(m) => m.FeatureFactoryComponent
53+
),
54+
},
4855
];

docs/docs/extensions.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ The NgRx Toolkit is a set of extensions to the NgRx SignalsStore.
77
It offers extensions like:
88

99
- [⭐️ Devtools](./with-devtools): Integration into Redux Devtools
10-
- [Redux](./with-redux): Possibility to use the Redux Pattern (Reducer, Actions, Effects)
1110
- [DataService](./with-data-service): Builds on top of `withEntities` and adds the backend synchronization to it
11+
- [Feature Factory](./with-feature-factory): Allows passing properties, methods, or signals from a SignalStore to a custom feature (`signalStoreFeature`).
12+
- [Immutable State Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store.
13+
- [Redux](./with-redux): Possibility to use the Redux Pattern (Reducer, Actions, Effects)
14+
- [Reset](./with-reset): Adds a `resetState` method to your store
1215
- [Storage Sync](./with-storage-sync): Synchronizes the Store with Web Storage
1316
- [Undo Redo](./with-undo-redo): Adds Undo/Redo functionality to your store
14-
- [Reset](./with-reset): Adds a `resetState` method to your store
15-
- [State Immutability Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store.
1617

1718
To install it, run
1819

docs/docs/with-feature-factory.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
---
2+
title: withFeatureFactory()
3+
---
4+
5+
The `withFeatureFactory()` function allows passing properties, methods, or signals from a SignalStore to a feature. It is an advanced feature, primarily targeted for library authors for SignalStore features.
6+
7+
Its usage is very simple. It is a function which gets the current store:
8+
9+
```typescript
10+
function withSum(a: Signal<number>, b: Signal<number>) {
11+
return signalStoreFeature(
12+
withComputed(() => ({
13+
sum: computed(() => a() + b()),
14+
}))
15+
);
16+
}
17+
18+
signalStore(
19+
withState({ a: 1, b: 2 }),
20+
withFeatureFactory((store) => withSum(store.a, store.b))
21+
);
22+
```
23+
24+
## Use Case 1: Mismatching Input Constraints
25+
26+
`signalStoreFeature` can define input constraints that must be fulfilled by the SignalStore calling the feature. For example, a method `load` needs to be present to fetch data. The default implementation would be:
27+
28+
```typescript
29+
import { signalStoreFeature } from '@ngrx/signals';
30+
31+
type Entity = {
32+
id: number;
33+
name: string;
34+
};
35+
36+
function withEntityLoader() {
37+
return signalStoreFeature(
38+
type<{
39+
methods: {
40+
load: (id: number) => Promise<Entity>;
41+
};
42+
}>(),
43+
withState({
44+
entity: undefined as Entity | undefined,
45+
}),
46+
withMethods((store) => ({
47+
async setEntityId(id: number) {
48+
const entity = await store.load(id);
49+
patchState(store, { entity });
50+
},
51+
}))
52+
);
53+
}
54+
```
55+
56+
The usage of `withEntityLoader` would be:
57+
58+
```typescript
59+
signalStore(
60+
withMethods((store) => ({
61+
load(id: number): Promise<Entity> {
62+
// some dummy implementation
63+
return Promise.resolve({ id, name: 'John' });
64+
},
65+
})),
66+
withEntityLoader()
67+
);
68+
```
69+
70+
A common issue with generic features is that the input constraints are not fulfilled exactly. If the existing `load` method would return an `Observable<Entity>`, we would have to rename that one and come up with a `load` returning `Promise<Entitiy>`. Renaming an existing method might not always be an option. Beyond that, what if two different features require a `load` method with different return types?
71+
72+
Another aspect is that we probably want to encapsulate the load method since it is an internal one. The current options don't allow that, unless the `withEntityLoader` explicitly defines a `_load` method.
73+
74+
For example:
75+
76+
```typescript
77+
signalStore(
78+
withMethods((store) => ({
79+
load(id: number): Observable<Entity> {
80+
return of({ id, name: 'John' });
81+
},
82+
})),
83+
withEntityLoader()
84+
);
85+
```
86+
87+
`withFeatureFactory` solves those issues by mapping the existing method to whatever `withEntityLoader` requires. `withEntityLoader` needs to move the `load` method dependency to an argument of the function:
88+
89+
```typescript
90+
function withEntityLoader(load: (id: number) => Promise<Entity>) {
91+
return signalStoreFeature(
92+
withState({
93+
entity: undefined as Entity | undefined,
94+
}),
95+
withMethods((store) => ({
96+
async setEntityId(id: number) {
97+
const entity = await load(id);
98+
patchState(store, { entity });
99+
},
100+
}))
101+
);
102+
}
103+
```
104+
105+
`withFeatureFactory` can now map the existing `load` method to the required one.
106+
107+
```typescript
108+
const store = signalStore(
109+
withMethods((store) => ({
110+
load(id: number): Observable<Entity> {
111+
// some dummy implementation
112+
return of({ id, name: 'John' });
113+
},
114+
})),
115+
withFeatureFactory((store) => withEntityLoader((id) => firstValueFrom(store.load(id))))
116+
);
117+
```
118+
119+
## Use Case 2: Generic features with Input Constraints
120+
121+
Another potential issue with advanced features in a SignalStore is that multiple
122+
features with input constraints cannot use generic types.
123+
124+
For example, `withEntityLoader` is a generic feature that allows the caller to
125+
define the entity type. Alongside `withEntityLoader`, there's another feature,
126+
`withOptionalState`, which has input constraints as well.
127+
128+
Due to [certain TypeScript limitations](https://ngrx.io/guide/signals/signal-store/custom-store-features#known-typescript-issues),
129+
the following code will not compile:
130+
131+
```ts
132+
function withEntityLoader<T>() {
133+
return signalStoreFeature(
134+
type<{
135+
methods: {
136+
load: (id: number) => Promise<T>;
137+
};
138+
}>(),
139+
withState({
140+
entity: undefined as T | undefined,
141+
}),
142+
withMethods((store) => ({
143+
async setEntityId(id: number) {
144+
const entity = await store.load(id);
145+
patchState(store, { entity });
146+
},
147+
}))
148+
);
149+
}
150+
151+
function withOptionalState<T>() {
152+
return signalStoreFeature(
153+
type<{ methods: { foo: () => string } }>(),
154+
withState({
155+
state: undefined as T | undefined,
156+
})
157+
);
158+
}
159+
160+
signalStore(
161+
withMethods((store) => ({
162+
foo: () => 'bar',
163+
load(id: number): Promise<Entity> {
164+
// some dummy implementation
165+
return Promise.resolve({ id, name: 'John' });
166+
},
167+
})),
168+
withOptionalState<Entity>(),
169+
withEntityLoader<Entity>()
170+
);
171+
```
172+
173+
Again, `withFeatureFactory` can solve this issue by replacing the input constraint with a function parameter:
174+
175+
```ts
176+
function withEntityLoader<T>(loader: (id: number) => Promise<T>) {
177+
return signalStoreFeature(
178+
withState({
179+
entity: undefined as T | undefined,
180+
}),
181+
withMethods((store) => ({
182+
async setEntityId(id: number) {
183+
const entity = await loader(id);
184+
patchState(store, { entity });
185+
},
186+
}))
187+
);
188+
}
189+
190+
function withOptionalState<T>(foo: () => string) {
191+
return signalStoreFeature(
192+
withState({
193+
state: undefined as T | undefined,
194+
})
195+
);
196+
}
197+
198+
signalStore(
199+
withMethods((store) => ({
200+
foo: () => 'bar',
201+
load(id: number): Promise<Entity> {
202+
// some dummy implementation
203+
return Promise.resolve({ id, name: 'John' });
204+
},
205+
})),
206+
withFeatureFactory((store) => withOptionalState<Entity>(store.foo.bind(store))),
207+
withFeatureFactory((store) => withEntityLoader<Entity>(store.load.bind(store)))
208+
);
209+
```

docs/sidebars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const sidebars: SidebarsConfig = {
2222
'with-storage-sync',
2323
'with-undo-redo',
2424
'with-immutable-state',
25+
'with-feature-factory',
2526
],
2627
reduxConnectorSidebar: [
2728
{

libs/ngrx-toolkit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
2121
export * from './lib/with-pagination';
2222
export { withReset, setResetState } from './lib/with-reset';
2323
export { withImmutableState } from './lib/immutable-state/with-immutable-state';
24+
export { withFeatureFactory } from './lib/with-feature-factory';

0 commit comments

Comments
 (0)