Simplifying Angular State Management Using NgRx SignalState
Daniel Sogl
Posted on March 29, 2024
Introduction to Angular Signals
With Angular v16, Angular has reimagined the development of reactive applications with the Signals API. 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 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.
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.
// 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.
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.
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.
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.
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.
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.
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.
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.
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.
Posted on March 29, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.