, ,

Extending the NgRx signal store with a custom feature

Feb 07, 2024 reading time 12 minutes

Extending the NgRx signal store with a custom feature

In this blog post, I want to show how we can extend the NgRx Signal Store with a custom feature and use it in an Angular application.

Extending the NgRx signal store with a custom feature

This blog post is inspired by https://www.angulararchitects.io/en/blog/the-new-ngrx-signal-store-for-angular-2-1-flavors/ by Manfred Steyer but goes a little bit deeper into creating generic features when using the signal store.

The NgRx Signal Store

The NgRx Team has released the new signal store which is fully based on signals to make your application ready for the next step towards state management with pure signals.

However, the NgRx Team did also design the signal store with extensibility in mind. What that means and how we can create a simple CRUD feature for the store as an example: We will explore all this in this blog post.

If you want to know how to get started with the NgRx signal store, read my previous blog post

Now that you know the basics of the NgRx store, we can start to implement a feature for it.

Preparing the generic feature

The store provides methods like withHooks, withMethods, withState, withComputed and so on. Another method is the signalStoreFeature method, which we can use to develop a specific feature for the store.

The signalStoreFeature method itself can also define the withHooks, withMethods, withState, withComputed methods, which makes it possible to use them for a feature and provide them into the store as a single line.

Let’s take a CRUD feature as an example which is generic and project it to the to-do case. So the result should be a generic feature which we can pass a service to, a state (or at least a part of a state), to make that feature take away the basic work for us.

Some things, however, can not be made generic, the specific selectors for that component for example, as they are very specific for your use cases inside the component. Of course there can be generic selectors, but if you want to display component specific values, you need to have them outside the generic feature.

Having that said, let us plan the feature a little before we write the code.



The signal store roughly looks like this

export const TodoStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),  
  withComputed(...),
  withMethods(...),
  withHooks(...)
);

And we can implement our feature in a very similar way that, in the end, it looks like this:

export const TodoStore = signalStore(
  { providedIn: 'root' },
  withState(initialState), // specific and/or generic
  withCrudOperations(...), // <---- Generic Feature
  withComputed(...), // specific
  withMethods(...), // specific
  withHooks(...) // specific
);

We want to make a feature that allows us to choose an item and a service for processing it. This feature must be flexible, so the item and service should follow specific rules to allow us to use the create, read, update, delete (CRUD) functions again. We will use the tools and steps this feature offers, add our own code in the TodoStore, and use everything together in our component.

Let us first create the entity, a to-do and a base entity in this case

export type BaseEntity = { id: string };

export interface Todo extends BaseEntity {
  value: string;
  done: boolean;
}

To use a service with the generic CRUD feature, we must set up an interface that ensures the service follows specific rules.

import { Observable } from 'rxjs';

export interface CrudService<T> {
  getItems(): Observable<T[]>;

  getItemsAsPromise(): Promise<T[]>; // Yes, we can also use promises instead of Observables

  getItem(id: string): Observable<T>;

  addItem(value: string): Observable<T>;

  updateItem(value: T): Observable<T>;

  deleteItem(value: T): Observable<any>;
}

And we need a service for our entity now, which implements that feature

@Injectable({
  providedIn: 'root',
})
export class TodoService implements CrudService<Todo> {
  private readonly http = inject(HttpClient);

  private url = `.../todos`;

  getItems(): Observable<Todo[]> {
    return this.http.get<Todo[]>(this.url);
  }

  getItemsAsPromise() {
    return lastValueFrom(this.http.get<Todo[]>(this.url));
  }

  getItem(id: string) {
    return this.http.get<Todo>(`${this.url}/${id}`);
  }

  addItem(value: string) {
    return this.http.post<Todo>(this.url, { value });
  }

  updateItem(value: Todo) {
    return this.http.put<Todo>(`${this.url}/${value.id}`, value);
  }

  deleteItem(value: Todo) {
    return this.http.delete(`${this.url}/${value.id}`);
  }
}

The generic feature only deals with the interface later.

Implementing the generic feature

Let’s create a new file called crud.state.ts and implement a feature for the signal store. We already know about the signalStoreFeature method, which then can define all the other methods as well.

Inside this file, we first create a feature

export function withCrudOperations() {
  return signalStoreFeature(
    // state goes here
    withMethods(/* ... */),
    withComputed(/* ... */)
  );
}


That basically is the “shell” for our feature.

But we need to make it generic in terms of dealing with a generic state and a generic entity. We can define a base state which is generic and use it inside our feature

export type BaseState<Entity> = {
  items: Entity[];
  loading: boolean;
};

export function withCrudOperations<Entity extends BaseEntity>() {
  return signalStoreFeature(
    {
      state: type<BaseState<Entity>>(),
    },
    withMethods(/* ... */),
    withComputed(/* ... */)
  );
}

Now the feature knows about its state and the entity it is dealing with. Next challenge would be to provide the generic service we did so that the methods can be used:

export type BaseState<Entity> = {
  items: Entity[];
  loading: boolean;
};

export function withCrudOperations<Entity extends BaseEntity>(
  dataServiceType: Type<CrudService<Entity>> // pass the service type here
) {
  return signalStoreFeature(
    {
      state: type<BaseState<Entity>>(),
    },
    withMethods(/* ... */),
    withComputed(/* ... */)
  );
}

We can pass the type of the service as an argument directly in the feature. That is neat!

Let us now add the selectors we can use in the component, then. As we do “only” know about the items of the base state, we can return the items itself and the length of the items, for example. You can extend this to your needs but be aware, that you can only deal with the items from the base state and properties from the base entity here

export type BaseState<Entity> = {
  items: Entity[];
  loading: boolean;
};

export function withCrudOperations<Entity extends BaseEntity>(
  dataServiceType: Type<CrudService<Entity>> // pass the service here
) {
  return signalStoreFeature(
    {
      state: type<BaseState<Entity>>(),
    },
    withMethods(/* ... */),

    withComputed(({ items }) => ({
      allItemsCount: computed(() => items().length),
      // Your selector here
    }))
  );
}

The next step would be to use the generic methods from the service interface, to call the get, update and delete methods:

For this, we can inject the service with the inject function because we only passed it as the type Type<CrudService<Entity>> into the feature as a parameter.

export function withCrudOperations<Entity extends BaseEntity>(
  dataServiceType: Type<CrudService<Entity>> // pass the service here
) {
  return signalStoreFeature(
    {
      state: type<BaseState<Entity>>(),
    },
    withMethods((store) => {
       const service = inject(dataServiceType);

       return {
           /* Method go here */
       }
    }),
    withComputed(({ items }) => ({
      allItems: computed(() => items()),
      allItemsCount: computed(() => items().length),
      // Your selector here
    }))
  );
}

Now as we have the service we can implement the methods as following

export function withCrudOperations<Entity extends BaseEntity>(
  dataServiceType: Type<CrudService<Entity>> // pass the service here
) {
  return signalStoreFeature(
    {
      state: type<BaseState<Entity>>(),
    },
    withMethods((store) => {
       const service = inject(dataServiceType);

      return {
        addItem: rxMethod<string>(
          pipe(
            switchMap((value) => {
              patchState(store, { loading: true });

              return service.addItem(value).pipe(
                tapResponse({
                  next: (addedItem) => {
                    patchState(store, {
                      items: [...store.items(), addedItem],
                    });
                  },
                  error: console.error,
                  finalize: () => patchState(store, { loading: false }),
                })
              );
            })
          )
        ),

        async loadAllItemsByPromise() {
          patchState(store, { loading: true });
          const items = await service.getItemsAsPromise();
          patchState(store, { items, loading: false });
        },

        deleteItem: rxMethod<Entity>(
          pipe(
            switchMap((item) => {
              patchState(store, { loading: true });

              return service.deleteItem(item).pipe(
                tapResponse({
                  next: () => {
                    patchState(store, {
                      items: [...store.items().filter((x) => x.id !== item.id)],
                    });
                  },
                  error: console.error,
                  finalize: () => patchState(store, { loading: false }),
                })
              );
            })
          )
        ),

        update: rxMethod<Entity>(
          pipe(
            switchMap((item) => {
              patchState(store, { loading: true });

              return service.updateItem(item).pipe(
                tapResponse({
                  next: (updatedItem) => {
                    const allItems = [...store.items()];
                    const index = allItems.findIndex((x) => x.id === item.id);

                    allItems[index] = updatedItem;

                    patchState(store, {
                      items: allItems,
                    });
                  },
                  error: console.error,
                  finalize: () => patchState(store, { loading: false }),
                })
              );
            })
          )
        ),
      };
    }),
    withComputed(({ items }) => ({
      allItems: computed(() => items()),
      allItemsCount: computed(() => items().length),
      // Your selectors here
    }))
  );
}

Using this feature in the component

Now as the generic feature is ready, all we need to do now is stick it together with the signal store we already have. This is the moment where the extensibility of the NgRx signal store really shines.

In our component we can add our method like the example in the beginning of the post. In a todo.store.ts file, we can drop it like

export interface TodoState extends BaseState<Todo> {}

export const initialState: TodoState = {
  items: [],
  loading: false,
};

export const TodoStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withCrudOperations<Todo>(TodoService), // Pass the service type and the entity here
  withTodoSelectors(),
  withMethods(/* ... */),
  withHooks(/* ... */)
);

We are using the Crud feature now, and we also extend this specific store with withTodoSelectors(). As this is now on the to-do and not base entity level, we can access more properties of the to-do and our crud feature stays generic.

export function withTodoSelectors() {
  return signalStoreFeature(
    {
      state: type<TodoState>(),
    },
    withComputed(({ items }) => ({
      doneCount: computed(() => items().filter((x) => x.done).length),
      undoneCount: computed(() => items().filter((x) => !x.done).length),
      percentageDone: computed(() => {
        const done = items().filter((x) => x.done).length;
        const total = items().length;

        if (total === 0) {
          return 0;
        }

        return (done / total) * 100;
      }),
    }))
  );
}

We can even define specific hooks and call methods from the generic feature here as well as re-map methods, if you find them more useful

export const TodoStore = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withCrudOperations<Todo>(TodoService),
  withTodoSelectors(),
  withMethods((store) => ({
    moveToDone(item: Todo) {
      store.update({ ...item, done: true });
    },
  })),
  withHooks({
    onInit({ loadAllItemsByPromise }) {
      console.log('on init');
      loadAllItemsByPromise();
    },
    onDestroy() {
      console.log('on destroy');
    },
  })
);

In the component then you can inject the TodoStore and use all the methods of the specific to-do store as well as the generic feature in your template.

import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterOutlet } from '@angular/router';
import { TodoStore } from './store/todo.state';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, ReactiveFormsModule],
  template: `<h2>
    Awesome Todo List
    {{ store.allItemsCount() }}
    ({{ store.doneCount() }}/{{ store.undoneCount() }}
    {{ store.percentageDone() | number : '1.0-0' }}% done)
  </h2> 
   ... <!-- form stuff -->`,
  styleUrl: './app.component.css',
  providers: [TodoStore],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
  readonly store = inject(TodoStore);

  private readonly formbuilder = inject(FormBuilder);

  form = this.formbuilder.group({
    todoValue: ['', Validators.required],
    done: [false],
  });

  addTodo() {
    this.store.addItem(this.form.value.todoValue);    
  }

  title = 'ngrx-signal-store-todo';
}

And that is how you can implement a generic feature for the new NgRx signal store.

I hope this helps

Fabian