Skip to content

Commit

Permalink
feat: add withFeatureFactory()
Browse files Browse the repository at this point in the history
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
rainerhahnekamp authored Feb 9, 2025
1 parent 8203d33 commit aead6f8
Show file tree
Hide file tree
Showing 10 changed files with 425 additions and 3 deletions.
14 changes: 14 additions & 0 deletions apps/demo/e2e/feature-factory.spec.ts
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();
});
});
1 change: 1 addition & 0 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<a mat-list-item routerLink="/todo-storage-sync">withStorageSync</a>
<a mat-list-item routerLink="/reset">withReset</a>
<a mat-list-item routerLink="/immutable-state">withImmutableState</a>
<a mat-list-item routerLink="/feature-factory">withFeatureFactory</a>
</mat-nav-list>
</mat-drawer>
<mat-drawer-content>
Expand Down
65 changes: 65 additions & 0 deletions apps/demo/src/app/feature-factory/feature-factory.component.ts
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);
}
}
7 changes: 7 additions & 0 deletions apps/demo/src/app/lazy-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,11 @@ export const lazyRoutes: Route[] = [
(m) => m.ImmutableStateComponent
),
},
{
path: 'feature-factory',
loadComponent: () =>
import('./feature-factory/feature-factory.component').then(
(m) => m.FeatureFactoryComponent
),
},
];
7 changes: 4 additions & 3 deletions docs/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ The NgRx Toolkit is a set of extensions to the NgRx SignalsStore.
It offers extensions like:

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

To install it, run

Expand Down
209 changes: 209 additions & 0 deletions docs/docs/with-feature-factory.md
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)))
);
```
1 change: 1 addition & 0 deletions docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const sidebars: SidebarsConfig = {
'with-storage-sync',
'with-undo-redo',
'with-immutable-state',
'with-feature-factory',
],
reduxConnectorSidebar: [
{
Expand Down
1 change: 1 addition & 0 deletions libs/ngrx-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export { withStorageSync, SyncConfig } from './lib/with-storage-sync';
export * from './lib/with-pagination';
export { withReset, setResetState } from './lib/with-reset';
export { withImmutableState } from './lib/immutable-state/with-immutable-state';
export { withFeatureFactory } from './lib/with-feature-factory';
Loading

0 comments on commit aead6f8

Please sign in to comment.