forked from angular-architects/ngrx-toolkit
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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)) ); ```
- Loading branch information
1 parent
8203d33
commit aead6f8
Showing
10 changed files
with
425 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { test, expect } from '@playwright/test'; | ||
|
||
test.describe('feature factory', () => { | ||
test.beforeEach(async ({ page }) => { | ||
await page.goto(''); | ||
await page.getByRole('link', { name: 'withFeatureFactory' }).click(); | ||
}); | ||
|
||
test(`loads user`, async ({ page }) => { | ||
await expect(page.getByText('Current User: -')).toBeVisible(); | ||
await page.getByRole('button', { name: 'Load User' }).click(); | ||
await expect(page.getByText('Current User: Konrad')).toBeVisible(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
65 changes: 65 additions & 0 deletions
65
apps/demo/src/app/feature-factory/feature-factory.component.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { Component, inject } from '@angular/core'; | ||
import { | ||
patchState, | ||
signalStore, | ||
signalStoreFeature, | ||
withMethods, | ||
withState, | ||
} from '@ngrx/signals'; | ||
import { MatButton } from '@angular/material/button'; | ||
import { FormsModule } from '@angular/forms'; | ||
import { lastValueFrom, of } from 'rxjs'; | ||
import { withFeatureFactory } from '@angular-architects/ngrx-toolkit'; | ||
|
||
type User = { | ||
id: number; | ||
name: string; | ||
}; | ||
|
||
function withMyEntity<Entity>(loadMethod: (id: number) => Promise<Entity>) { | ||
return signalStoreFeature( | ||
withState({ | ||
currentId: 1 as number | undefined, | ||
entity: undefined as undefined | Entity, | ||
}), | ||
withMethods((store) => ({ | ||
async load(id: number) { | ||
const entity = await loadMethod(1); | ||
patchState(store, { entity, currentId: id }); | ||
}, | ||
})) | ||
); | ||
} | ||
|
||
const UserStore = signalStore( | ||
{ providedIn: 'root' }, | ||
withMethods(() => ({ | ||
findById(id: number) { | ||
return of({ id: 1, name: 'Konrad' }); | ||
}, | ||
})), | ||
withFeatureFactory((store) => { | ||
const loader = (id: number) => lastValueFrom(store.findById(id)); | ||
return withMyEntity<User>(loader); | ||
}) | ||
); | ||
|
||
@Component({ | ||
template: ` | ||
<h2> | ||
<pre>withFeatureFactory</pre> | ||
</h2> | ||
<button mat-raised-button (click)="loadUser()">Load User</button> | ||
<p>Current User: {{ userStore.entity()?.name || '-' }}</p> | ||
`, | ||
imports: [MatButton, FormsModule], | ||
}) | ||
export class FeatureFactoryComponent { | ||
protected readonly userStore = inject(UserStore); | ||
|
||
loadUser() { | ||
void this.userStore.load(1); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
--- | ||
title: 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)) | ||
); | ||
``` | ||
|
||
## Use Case 1: Mismatching Input Constraints | ||
|
||
`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: | ||
|
||
```typescript | ||
import { signalStoreFeature } from '@ngrx/signals'; | ||
|
||
type Entity = { | ||
id: number; | ||
name: string; | ||
}; | ||
|
||
function withEntityLoader() { | ||
return signalStoreFeature( | ||
type<{ | ||
methods: { | ||
load: (id: number) => Promise<Entity>; | ||
}; | ||
}>(), | ||
withState({ | ||
entity: undefined as Entity | undefined, | ||
}), | ||
withMethods((store) => ({ | ||
async setEntityId(id: number) { | ||
const entity = await store.load(id); | ||
patchState(store, { entity }); | ||
}, | ||
})) | ||
); | ||
} | ||
``` | ||
|
||
The usage of `withEntityLoader` would be: | ||
|
||
```typescript | ||
signalStore( | ||
withMethods((store) => ({ | ||
load(id: number): Promise<Entity> { | ||
// some dummy implementation | ||
return Promise.resolve({ id, name: 'John' }); | ||
}, | ||
})), | ||
withEntityLoader() | ||
); | ||
``` | ||
|
||
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? | ||
|
||
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. | ||
|
||
For example: | ||
|
||
```typescript | ||
signalStore( | ||
withMethods((store) => ({ | ||
load(id: number): Observable<Entity> { | ||
return of({ id, name: 'John' }); | ||
}, | ||
})), | ||
withEntityLoader() | ||
); | ||
``` | ||
|
||
`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: | ||
|
||
```typescript | ||
function withEntityLoader(load: (id: number) => Promise<Entity>) { | ||
return signalStoreFeature( | ||
withState({ | ||
entity: undefined as Entity | undefined, | ||
}), | ||
withMethods((store) => ({ | ||
async setEntityId(id: number) { | ||
const entity = await load(id); | ||
patchState(store, { entity }); | ||
}, | ||
})) | ||
); | ||
} | ||
``` | ||
|
||
`withFeatureFactory` can now map the existing `load` method to the required one. | ||
|
||
```typescript | ||
const store = signalStore( | ||
withMethods((store) => ({ | ||
load(id: number): Observable<Entity> { | ||
// some dummy implementation | ||
return of({ id, name: 'John' }); | ||
}, | ||
})), | ||
withFeatureFactory((store) => withEntityLoader((id) => firstValueFrom(store.load(id)))) | ||
); | ||
``` | ||
|
||
## Use Case 2: Generic features with Input Constraints | ||
|
||
Another potential issue with advanced features in a SignalStore is that multiple | ||
features with input constraints cannot use generic types. | ||
|
||
For example, `withEntityLoader` is a generic feature that allows the caller to | ||
define the entity type. Alongside `withEntityLoader`, there's another feature, | ||
`withOptionalState`, which has input constraints as well. | ||
|
||
Due to [certain TypeScript limitations](https://ngrx.io/guide/signals/signal-store/custom-store-features#known-typescript-issues), | ||
the following code will not compile: | ||
|
||
```ts | ||
function withEntityLoader<T>() { | ||
return signalStoreFeature( | ||
type<{ | ||
methods: { | ||
load: (id: number) => Promise<T>; | ||
}; | ||
}>(), | ||
withState({ | ||
entity: undefined as T | undefined, | ||
}), | ||
withMethods((store) => ({ | ||
async setEntityId(id: number) { | ||
const entity = await store.load(id); | ||
patchState(store, { entity }); | ||
}, | ||
})) | ||
); | ||
} | ||
|
||
function withOptionalState<T>() { | ||
return signalStoreFeature( | ||
type<{ methods: { foo: () => string } }>(), | ||
withState({ | ||
state: undefined as T | undefined, | ||
}) | ||
); | ||
} | ||
|
||
signalStore( | ||
withMethods((store) => ({ | ||
foo: () => 'bar', | ||
load(id: number): Promise<Entity> { | ||
// some dummy implementation | ||
return Promise.resolve({ id, name: 'John' }); | ||
}, | ||
})), | ||
withOptionalState<Entity>(), | ||
withEntityLoader<Entity>() | ||
); | ||
``` | ||
|
||
Again, `withFeatureFactory` can solve this issue by replacing the input constraint with a function parameter: | ||
|
||
```ts | ||
function withEntityLoader<T>(loader: (id: number) => Promise<T>) { | ||
return signalStoreFeature( | ||
withState({ | ||
entity: undefined as T | undefined, | ||
}), | ||
withMethods((store) => ({ | ||
async setEntityId(id: number) { | ||
const entity = await loader(id); | ||
patchState(store, { entity }); | ||
}, | ||
})) | ||
); | ||
} | ||
|
||
function withOptionalState<T>(foo: () => string) { | ||
return signalStoreFeature( | ||
withState({ | ||
state: undefined as T | undefined, | ||
}) | ||
); | ||
} | ||
|
||
signalStore( | ||
withMethods((store) => ({ | ||
foo: () => 'bar', | ||
load(id: number): Promise<Entity> { | ||
// some dummy implementation | ||
return Promise.resolve({ id, name: 'John' }); | ||
}, | ||
})), | ||
withFeatureFactory((store) => withOptionalState<Entity>(store.foo.bind(store))), | ||
withFeatureFactory((store) => withEntityLoader<Entity>(store.load.bind(store))) | ||
); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.