Simplifying Angular State Management Using NgRx SignalState
Tags: angular, typescript, webdev, tutorial
Published: 2024-03-29 16:40:42

## Introduction to Angular Signals

With Angular v16, Angular has reimagined the development of reactive applications with the [Signals API](https://angular.io/guide/signals). After the basics of the API were established, Signal Inputs and Signal Outputs were added in recent Angular v17 releases. Therefore, nothing stands in the way of converting an existing application to the new API.

In addition to Angular itself, widely used libraries have also introduced the new Signals API, including the State Management Library NgRx, which is either loved or hated by developers.


## Introducing NgRx SignalState

[NgRx](https://ngrx.io/) is the standard library for state management in Angular applications. With NgRx v14, many of the complex APIs following the Redux pattern have been greatly simplified. For example, ActionGroups make it easier to define new actions. However, the use of the Redux pattern is by no means easy and discourages many developers.

With Angular v17, a new state management library was released by the NgRx team, which fully relies on the new Signal API, namely NgRx-Signal Store. However, an even lighter alternative was released, the so-called [SignalState](https://ngrx.io/guide/signals/signal-state).

SignalState allows easy management of states of components or services based on the Signal API. The API is deliberately minimalist. In addition to defining a state within a component or service, there is only the possibility to change the state. Actions, Effects or Reducer are not necessary.


## Getting Started with SignalState

First, we need to add the dependency to our Angular project. This can be done either conveniently via a corresponding 'ng add' command or through manual installation via npm.

```bash
// using ng add
ng add @ngrx/signals@latest

// using npm
npm install @ngrx/signals@latest --save

```


## Define SignalState

After adding the dependency to our Angular application, we first look at how to define a state with the `signalState` function.

```tsx
import { signalState } from "@ngrx/signals";

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

type TodoState = { todos: Todo[]; selectedTodo: Todo | null };

const todoState = signalState<TodoState>({
  todos: [],
  selectedTodo: null,
});

```

As you can see, the state can be defined quite easily with the help of an interface or type and calling the `signalState` function with the default state as a parameter.


## Consume SignalState

We can now consume the state as signals. Of course, this can also be done reactively via the `computed` or `effect` methods.

Only read access to the signal properties is possible. This ensures that the state cannot be manipulated from the outside.

```tsx
import { computed, effect } from '@angular/core';

const todosCounter = computed(() => todoState().todos.length);

effect(() => console.log("selectedTodo", todoState().selectedTodo));

```

The function `signalState` automatically generates its own signals for each property defined in the State, which we can also use.

```tsx
import { computed, effect } from '@angular/core';

const todos = todoState.todos;
const selectedTodo = todoState.selectedTodo;

const todosCounter = computed(() => todos().length);

effect(() => console.log("selectedTodo", selectedTodo()));

```

When we want to consume nested data like our Todo object in our store, the `signalState` function creates so-called DeepSignals for the individual properties.

```tsx
import { computed, effect } from '@angular/core';

const selectedTodo = todoState.selectedTodo;

const selectedTodoId = selectedTodo.id;
const selectedTodoText = selectedTodo.text;
const selectedTodoCompleted = selectedTodo.completed;

console.log(selectedTodoId());
console.log(selectedTodoText());
console.log(selectedTodoCompleted());
```


## Updating SignalState

Now we only lack the last building block to be able to work fully with the SignalState, namely the update of our state. It is important to keep in mind that the update must be done immutable. Thanks to the spread operator, this is fortunately not a real problem in practice.

```tsx
import { patchState } from '@ngrx/signals';

patchState(todoState, {
  selectedTodo: {
    id: 1,
    text: "Lorem ipsum",
    completed: false,
  },
});

patchState(todoState, (state: TodoState) => ({
  selectedTodo: { ...state.selectedTodo!, completed: true },
}));

```

Recurring update operations can be stored in their own state update functions to avoid duplication.

```tsx
import { PartialStateUpdater } from '@ngrx/signals';

function setCompleted(completed: boolean): PartialStateUpdater<TodoState> {
  return (state) => ({
    selectedTodo: {
      ...state.selectedTodo!,
      completed,
    },
  });
}

function addTodo(todo: Todo): PartialStateUpdater<TodoState> {
  return (state) => ({ todos: [...state.todos, todo] });
}

```


## Example: Managing Component State

After we have looked at the SignalState API, it is now time to convert our Angular application using practical examples. For this, I am using the Todo example that I have already shown.

```tsx
import { Component, computed, effect } from '@angular/core';
import { patchState, signalState } from '@ngrx/signals';
import { Todo } from '../../models/todo';

type TodoPageState = {
  todos: Todo[];
};

@Component({
  selector: 'app-todo-page',
  standalone: true,
  templateUrl: './todo-page.component.html',
  styleUrl: './todo-page.component.css',
})
export class TodoPageComponent {
  private readonly todoPageState = signalState<TodoPageState>({ todos: [] });
  private idCounter = 1;

  protected readonly todos = this.todoPageState.todos;
  protected readonly todoCounter = computed(
    () => this.todos().length
  );

  constructor() {
    effect(() => console.log('Todos changed:', this.todoPageState.todos()));
  }

  addTodo(todo: Omit<Todo, 'id'>): void {
    const todos = this.todoPageState.todos();
    patchState(this.todoPageState, {
      todos: [
        ...todos,
        {
          ...todo,
          id: this.idCounter++,
        },
      ],
    });
  }

  removeTodo(id: number): void {
    const todos = this.todoPageState.todos();
    patchState(this.todoPageState, {
      todos: todos.filter((todo) => todo.id !== id),
    });
  }

  completeTodo(id: number, completed: boolean): void {
    const todos = this.todoPageState.todos();
    patchState(this.todoPageState, {
      todos: todos.map((todo) =>
        todo.id === id ? { ...todo, completed } : todo
      ),
    });
  }
}

```


## Example: Managing Service State

In addition to the local state of a component, the state of a feature can also be easily managed with the help of a service.

```tsx
import { Injectable, computed } from '@angular/core';
import { patchState, signalState } from '@ngrx/signals';
import { Todo } from '../models/todo';

export type TodoState = {
  todos: Todo[];
  selectedTodo: Todo | null;
};

@Injectable({
  providedIn: 'root',
})
export class TodoService {
  private readonly todoState = signalState<TodoState>({
    todos: [],
    selectedTodo: null,
  });
  private idCounter = 1;

  public readonly todos = this.todoState.todos;
  public readonly selectedTodo = this.todoState.selectedTodo;
  public readonly todoCounter = computed(() => this.todos().length);

  public add(todo: Omit<Todo, 'id'>): void {
    patchState(this.todoState, {
      todos: [...this.todoState.todos(), { ...todo, id: this.idCounter++ }],
    });
  }

  public delete(id: number): void {
    const todos = this.todos();
    patchState(this.todoState, {
      todos: [...todos.filter((todo) => todo.id !== id)],
    });
  }

  public select(id: number): void {
    const todos = this.todos();
    patchState(this.todoState, {
      selectedTodo: todos.find((todo) => todo.id === id) || null,
    });
  }

  public complete(complete: boolean): void {
    const selectedTodo = this.selectedTodo();
    if (selectedTodo) {
      patchState(this.todoState, {
        selectedTodo: {
          ...selectedTodo,
          completed: complete,
        },
      });
    }
  }
}

```


## Conclusion

It is undeniable that the Angular Framework will release new features based on the Signals API in the future. Therefore, it is all the more important to build our Angular applications or future developments on this new standard. Especially in larger projects with multiple developers, the lightweight SignalState API from NgRx allows for uniform and structured work with Signals. In many cases, this is already sufficient to achieve this goal.