DEV Community

Cover image for Simplifying Angular State Management Using NgRx SignalState
Daniel Sogl
Daniel Sogl

Posted on

Simplifying Angular State Management Using NgRx SignalState

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

Enter fullscreen mode Exit fullscreen mode

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,
});

Enter fullscreen mode Exit fullscreen mode

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));

Enter fullscreen mode Exit fullscreen mode

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()));

Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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 },
}));

Enter fullscreen mode Exit fullscreen mode

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] });
}

Enter fullscreen mode Exit fullscreen mode

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
      ),
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

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,
        },
      });
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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.

Top comments (4)

Collapse
 
jangelodev profile image
João Angelo

Hi Daniel Sogl,
Your tips are very useful
Thanks for sharing

Collapse
 
danielsogl profile image
Daniel Sogl

Thanks for your feedback!

Collapse
 
matheo profile image
Mateo Tibaquirá

Great stuff! Thanks for sharing
I'm playing with the signalStore and I'm already thinking on the best practices we should follow to not mix signals and methods because they look the same in code() hehe. Many ngrx advices are deprecated now, we can have a compact and lightweight state "class", with actionable methods.
Now we need to organize the effects consistently to avoid a mess!

Collapse
 
danielsogl profile image
Daniel Sogl • Edited

Thanks for your thoughts! I’am preferring the full SignalStore with your described architecture