9

The Power of @ngrx/signalstore: A Deep Dive Into Task Management

 8 months ago
source link: https://dzone.com/articles/the-power-of-ngrxsignalstore-a-deep-dive-into-task
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

The Power of @ngrx/signalstore: A Deep Dive Into Task Management

In this tutorial, dive into the dynamic world of Angular state management, supercharged by Signal Store, introduced in NgRx 17.

Jan. 05, 24 · Tutorial
Like (2)
3.6K Views

The domain of Angular state management has received a huge boost with the introduction of Signal Store, a lightweight and versatile solution introduced in NgRx 17. Signal Store stands out for its simplicity, performance optimization, and extensibility, making it a compelling choice for modern Angular applications.

In the next steps, we'll harness the power of Signal Store to build a sleek Task Manager app. Let's embark on this journey to elevate your Angular application development. Ready to start building? Let's go!

A Glimpse Into Signal Store’s Core Structure

Signal Store revolves around four fundamental components that form the backbone of its state management capabilities:

1. State

At the heart of Signal Store lies the concept of signals, which represent the application's state in real-time. Signals are observable values that automatically update whenever the underlying state changes.

2. Methods

Signal Store provides methods that act upon the state, enabling you to manipulate and update it directly. These methods offer a convenient way to interact with the state and perform actions without relying on observable streams or external state managers.

3. Selectors

Selectors are functions that derive calculated values from the state. They provide a concise and maintainable approach to accessing specific parts of the state without directly exposing it to components. Selectors help encapsulate complex state logic and improve the maintainability of applications.

4. Hooks

Hooks are functions that are triggered at critical lifecycle events, such as component initialization and destruction. They allow you to perform actions based on these events, enabling data loading, state updates, and other relevant tasks during component transitions.

Creating a Signal Store and Defining Its State

To embark on your Signal Store journey, you'll need to install the @ngrx/signals package using npm:

But first, you have to install the Angular CLI and create an Angular base app with:

JavaScript
npm install -g @angular/cli@latest
JavaScript
ng new <name of your project>
JavaScript
npm install @ngrx/signals

Creating a state (distinct from a store) is the subsequent step:

TypeScript
import { signalState } from '@ngrx/signals';

 const state = signalState({ /* State goes here */ });

Manipulating the state becomes an elegant affair using the patchState method:

TypeScript
updateStateMethod() {

   patchState(this.state, (state) => ({ someProp: state.someProp + 1 }));

}

The patchState method is a fundamental tool for updating the state. It allows you to modify the state in a shallow manner, ensuring that only the specified properties are updated. This approach enhances performance by minimizing the number of state changes.

First Steps for the Task Manager App

First, create your interface for a Task and place it in a task.ts file:

TypeScript
export interface Task { 

   id: string; 

   value: string; 

   completed: boolean; 

}

The final structure of the app is:

Final app structure

And our TaskService in taskService.ts looks like this:

TypeScript

@Injectable({

 providedIn: 'root'

})

export class TaskService {



 private taskList: Task[] = [

   { id: '1', value: 'Complete task A', completed: false },

   { id: '2', value: 'Read a book', completed: true },

   { id: '3', value: 'Learn Angular', completed: false },

 ];



 constructor() { }



 getTasks() : Observable<Task[]> {

   return of(this.taskList);

 }



   getTasksAsPromise() {

   return lastValueFrom(this.getTasks());

 }



 getTask(id: string): Observable<Task | undefined> {

   const task = this.taskList.find(t => t.id === id);

   return of(task);

 }



 addTask(value: string): Observable<Task> {

   const newTask: Task = {

     id: (this.taskList.length + 1).toString(), // Generating a simple incremental ID

     value,

     completed: false

   };



   this.taskList = [...this.taskList, newTask];



   return of(newTask);

 }



 updateTask(updatedTask: Task): Observable<Task> {

   const index = this.taskList.findIndex(task => task.id === updatedTask.id);

   if (index !== -1) {

     this.taskList[index] = updatedTask;

   }



   return of(updatedTask);

 }



 deleteTask(task: Task): Observable<Task> {

   this.taskList = this.taskList.filter(t => t.id !== task.id);

   return of(task);

 }

}

Crafting a Signal Store for the Task Manager App

The creation of a store is a breeze with the signalStore method:

Create the signalStore and place it in the taskstate.ts file:

TypeScript
import { signalStore, withHooks, withState } from '@ngrx/signals';

 

export const TaskStore = signalStore(

  { providedIn: 'root' },

  withState({ /* state goes here */ }),

);

Taking store extensibility to new heights, developers can add methods directly to the store. Methods act upon the state, enabling you to manipulate and update it directly.

TypeScript
export interface TaskState {

   tasks: Task[];

   loading: boolean;

}



export const initialState: TaskState = {

   tasks: [];

   loading: false;

}

 

export const TaskStore = signalStore(

  { providedIn: 'root' },

  withState(initialState),

  withMethods((store, taskService = inject(TaskService)) => ({

    loadAllTasks() {

      // Use TaskService and then

      patchState(store, { tasks });

    },

  }))

);

This method loadAllTasks is now available directly through the store itself. So in the component, we could do it in ngOnInit():

TypeScript
@Component({

  // ...

  providers: [TaskStore],

})

export class AppComponent implements OnInit {

  readonly store = inject(TaskStore);



  ngOnInit() {

     this.store.loadAllTasks();

  }

}

Harmony With Hooks

The Signal Store introduces its own hooks, simplifying component code. By passing implemented methods into the hooks, developers can call them effortlessly: 

TypeScript
export const TaskStore = signalStore(

  { providedIn: 'root' },

  withState(initialState),

  withMethods(/* ... */),

  withHooks({

    onInit({ loadAllTasks }) {

      loadAllTasks();

    },

    onDestroy() {

      console.log('on destroy');

    },

  })

);

This results in cleaner components, exemplified in the following snippet:

TypeScript
@Component({

  providers: [TaskStore],

})

export class AppComponent implements OnInit {

  readonly store = inject(TaskStore);

 

   // ngOnInit is NOT needed to load the Tasks !!!!

}

RxJS and Promises in Methods

Flexibility takes center stage as @ngrx/signals seamlessly accommodates both RxJS and Promises:

TypeScript
import { rxMethod } from '@ngrx/signals/rxjs-interop';

export const TaskStore = signalStore(

  { providedIn: 'root' },

  withState({ /* state goes here */ }),

  withMethods((store, taskService = inject(TaskService)) => ({

    loadAllTasks: rxMethod<void>(

      pipe(

        switchMap(() => {

          patchState(store, { loading: true });

 

          return taskService.getTasks().pipe(

            tapResponse({

              next: (tasks) => patchState(store, { tasks }),

              error: console.error,

              finalize: () => patchState(store, { loading: false }),

            })

          );

        })

      )

    ),

  }))

);

 

This snippet showcases the library's flexibility in handling asynchronous operations with RxJS.

What I find incredibly flexible is that you can use RxJS or Promises to call your data. In the above example, you can see that we are using an RxJS in our methods. The tapResponse method helps us to use the response and manipulate the state with patchState again.

But you can also use promises. The caller of the method (the hooks in this case) do not care.

TypeScript
async loadAllTasksByPromise() {

   patchState(store, { loading: true });



   const tasks = await taskService.getTasksAsPromise();



   patchState(store, { tasks, loading: false });

},

Reading the Data With Finesse

Experience, the Signal Store introduces the withComputed() method. Similar to selectors, this method allows developers to compose and calculate values based on state properties: 

TypeScript
export const TaskStore = signalStore(

  { providedIn: 'root' },

  withState(initialState),

  withComputed(({ tasks }) => ({

        completedCount: computed(() => tasks().filter((x) => x.completed).length),

        pendingCount: computed(() => tasks().filter((x) => !x.completed).length),

        percentageCompleted: computed(() => {

        const completed = tasks().filter((x) => x.completed).length;

        const total = tasks().length;

 

        if (total === 0) {

            return 0;

        }

 

        return (completed / total) * 100;

    }),

  })),

  withMethods(/* ... */),

  withHooks(/* ... */)

);

 

In the component, these selectors can be effortlessly used:

TypeScript
@Component({

  providers: [TaskStore],

  templates: `

    <div> 

      {{ store.completedCount() }} / {{ store.pendingCount() }}

      {{ store.percentageCompleted() }}

    </div> `

})

export class AppComponent implements OnInit {

  readonly store = inject(TaskStore);

}

Modularizing for Elegance

To elevate the elegance, selectors, and methods can be neatly tucked into separate files. We use in these files the signalStoreFeature method. With this, we can extract the methods and selectors to make the store even more beautiful. This method again has withComputed, withHooks, and withMethods for itself, so you can build your own features and hang them into the store.

// task.selectors.ts:

TypeScript
export function withTasksSelectors() {

   return signalStoreFeature(

       {state: type<TaskState>()},

       withComputed(({tasks}) => ({

           completedCount: computed(() => tasks().filter((x) => x.completed).length),

           pendingCount: computed(() => tasks().filter((x) => !x.completed).length),

           percentageCompleted: computed(() => {

               const completed = tasks().filter((x) => x.completed).length;

               const total = tasks().length;



               if (total === 0) {

                   return 0;

               }

               return (completed / total) * 100;

           }),

       })),

   );

}

// task.methods.ts:

TypeScript
export function withTasksMethods() {

 return signalStoreFeature(

   { state: type<TaskState>() },

   withMethods((store, taskService = inject(TaskService)) => ({

     loadAllTasks: rxMethod<void>(

       pipe(

         switchMap(() => {

           patchState(store, { loading: true });



           return taskService.getTasks().pipe(

             tapResponse({

               next: (tasks) => patchState(store, { tasks }),

               error: console.error,

               finalize: () => patchState(store, { loading: false }),

             })

           );

         })

       )

     ),

     async loadAllTasksByPromise() {

       patchState(store, { loading: true });



       const tasks = await taskService.getTasksAsPromise();



       patchState(store, { tasks, loading: false });

     },

     addTask: rxMethod<string>(

       pipe(

         switchMap((value) => {

           patchState(store, { loading: true });



           return taskService.addTask(value).pipe(

             tapResponse({

               next: (task) =>

                 patchState(store, { tasks: [...store.tasks(), task] }),

               error: console.error,

               finalize: () => patchState(store, { loading: false }),

             })

           );

         })

       )

     ),

     moveToCompleted: rxMethod<Task>(

       pipe(

         switchMap((task) => {

           patchState(store, { loading: true });



           const toSend = { ...task, completed: !task.completed };



           return taskService.updateTask(toSend).pipe(

             tapResponse({

               next: (updatedTask) => {

                 const allTasks = [...store.tasks()];

                 const index = allTasks.findIndex((x) => x.id === task.id);



                 allTasks[index] = updatedTask;



                 patchState(store, {

                     tasks: allTasks,

                 });

               },

               error: console.error,

               finalize: () => patchState(store, { loading: false }),

             })

           );

         })

       )

     ),



     deleteTask: rxMethod<Task>(

       pipe(

         switchMap((task) => {

           patchState(store, { loading: true });



           return taskService.deleteTask(task).pipe(

             tapResponse({

               next: () => {

                 patchState(store, {

                   tasks: [...store.tasks().filter((x) => x.id !== task.id)],

                 });

               },

               error: console.error,

               finalize: () => patchState(store, { loading: false }),

             })

           );

         })

       )

     ),

   }))

 );

} 

This modular organization allows for a clean separation of concerns, making the store definition concise and easy to maintain.

Streamlining the Store Definition

With selectors and methods elegantly tucked away in their dedicated files, the store definition now takes on a streamlined form:

// task.store.ts:

TypeScript
export const TaskStore = signalStore(

 { providedIn: 'root' },

 withState(initialState),

 withTasksSelectors(),

 withTasksMethods(),

 withHooks({

   onInit({ loadAllTasksByPromise: loadAllTasksByPromise }) {

     console.log('on init');

     loadAllTasksByPromise();

   },

   onDestroy() {

     console.log('on destroy');

   },

 })

);

This modular approach not only enhances the readability of the store definition but also facilitates easy maintenance and future extensions.

Our AppComponent then can get the Store injected and use the methods from the store, the selectors, and using the hooks indirectly.

TypeScript
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, ReactiveFormsModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css',
  providers: [TaskStore],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
  readonly store = inject(TaskStore);

  private readonly formbuilder = inject(FormBuilder);

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

  addTask() {
    this.store.addTask(this.form.value.taskValue);
    this.form.reset();
  }

}

The final app:

Task Summary

In Closing

In this deep dive into the @ngrx/signals library, we've unveiled a powerful tool for Angular state management. From its lightweight architecture to its seamless integration of RxJS and Promises, the library offers a delightful development experience.

As you embark on your Angular projects, consider the elegance and simplicity that @ngrx/signals brings to the table. Whether you're starting a new endeavor or contemplating an upgrade, this library promises to be a valuable companion, offering a blend of simplicity, flexibility, and power in the dynamic world of Angular development. 

You can find the final code here.

Happy coding!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK