Telerik blogs

This guide focuses on the NgRx Signals library. We will build two simple applications—a random number generator and a movie listing app. These apps will cover both simple and complex state management using this library.

State is data that powers our applications, and state management focuses on how this data is created, retrieved and distributed among and within components in our app.

In Angular applications, stateful data is stored using instance variables, observables or signals. Every state management library that exists in the community is typically based on one or more of these primitives.

This guide focuses on the NgRx Signals library, which, as the name suggests, is dedicated to signals. Signals came in Angular 16, providing an easy way to manage stateful data and build highly reactive applications. However, state management using plain signals can become a pain as components grow and applications scale. The NgRx Signals library provides a simple, elegant and declarative way to manage stateful data using signals.

In this guide, we will build two simple applications—a random number generator and a movie listing app. These apps will cover both simple and complex state management using this library.

Project Setup

Let’s start by creating a basic angular application. Run the following command in your terminal:

ng new signal-store

In the prompt, select the default options. After that, a basic Angular application will be created in a folder named signal-store.

Next, let’s install the dependencies we will be needing. Run this command in your terminal to install the @ngrx/signals to your project.

npm i @ngrx/signals

The other dependencies we will need are just for styling and will be omitted in this guide for brevity. I installed Tailwind CSS and added two of its plugins, typography and daisyUI.

NgRx Signals—What’s in the Box?

The NgRx Signals library is strictly geared toward state management using signals and provides two options for configuring state in our apps. For simple state management in components and services, we use the signalState() function. For more complex states that will be used across multiple components in applications, we use the signalStore() function.

What We Will Be Building

As mentioned earlier, we will be building two simple applications. The first is a random number generator based on the signalState() function. This mini-app allows a user to generate a random number by clicking a button and maintains a history of the generated random numbers.

Random number generator

We will take some insights from this random number generator, as it will form the basis of the next application, the movie gallery application. The movie gallery app will be based on the signalStore() function. It will display a list of movies and allow users to select and delete some movies.

Movie gallery app

All terminal commands specified as we build each app assume we are in our Angular project’s src directory.

A Simple Random Number Generator

Run this command in your terminal to create a file named random-number-generator.component.ts.

touch random-number-generator.component.ts

We will now update this file progressively. Let’s start by defining a basic structure for the RandomNumberGeneratorComponent.

import { Component, computed, OnInit } from "@angular/core";

@Component({
  selector: "random-number-generator",
  template: ``,
  styles: [``],
  standalone: true,
})
export class RandomNumberGeneratorComponent implements OnInit {}

Next, let’s define the state we will be needing.

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

type RandomNumberGeneratorState = {
  generatedNumbers: number[];
};

@Component({
  //....
})
export class RandomNumberGeneratorComponent implements OnInit {
  randomNumberState = signalState<RandomNumberGeneratorState>({
    generatedNumbers: [],
  });
}

The signalState function expects a plain javascript object, and we pass an object representing the state of our random number generator. This object has only one property called generatedNumbers, and it is an array that holds a collection of all the random numbers.

The object passed to signalStore or signalState must be a plain object and can be nested to any depth.

The NgRx Signals library embraces immutability in how state is modified and accessed, so the call to signalState resolves to a read-only signal representation of its argument, which is the plain object passed.

In our case, RandomNumberGeneratorState now holds a read-only signal of the object passed to the signalState call.

UI displays the number of generated random numbers

As shown above, part of what we require from our UI is to display the number of random numbers that have been generated. For that, let’s generate a computed signal.

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

export class RandomNumberGeneratorComponent implements OnInit {
  randomNumberState = //...
    (noOfRandomNumbers = computed(
      () => this.randomNumberState().generatedNumbers.length
    ));
}

Computed signals are read-only signals derived from other signals using the computed function that Angular provides. This function receives a callback, and whatever is returned by this callback is wrapped in a read-only signal.

In our case, the noOfRandomNumbers signal is derived from a callback whose internals accesses and returns the number of the generated numbers using generatedNumbers.length.

Callbacks passed to computed calls are evaluated and called again anytime any signal used within the function body changes. In our case, each time the randomNumberState signal changes, noOfRandomNumbers is recomputed.

Let’s go ahead and update our UI.

@Component({
  selector: 'random-number-generator',
  template: `
    <div class="grid justify-center mt-7">
      <button class="btn btn-active btn-accent" (click)="addRandomNumber()">
        Add random number
      </button>
      <h1 class="text-center text-2xl font-bold">
        total of {{ noOfRandomNumbers() }} random number(s) generated
      </h1>
      <div class="mt-7 divide-y-2 grid border-2 mb-9">
        @for (number of randomNumberState.generatedNumbers(); track $index) {
        <h2>
          <b class="border-r-2 py-3 px-2 w-12 inline-block"
            >{{ $index + 1 }}.</b
          >
          {{ number }}
        </h2>
        }
      </div>
    </div>
  `,
  styles: [``],
  standalone: true,
})

The rendered UI contains the list of random numbers and the number of generated numbers using the randomNumberState and noOfRandomNumbers signals, respectively. We also included a button that allows us to add a random number and invokes an addRandomNumber function, which we have yet to define. Let us update this file.

import { getState, patchState, signalState } from '@ngrx/signals';
@Component({
//.....
})
export class RandomNumberGeneratorComponent implements OnInit {
  randomNumberState = //...
  noOfRandomNumbers =  //...
  addRandomNumber() {
    const randomNumber =  Math.random();
    patchState(this.randomNumberState,
      (state) => ({
        ...state,
        generatedNumbers: [randomNumber, ...state.generatedNumbers],
      })
);
  }
}

The addRandomNumber method does two things: It generates and appends a random number at the beginning of our already existing list of random numbers, and to make this update, we invoke the patchState() function, which is the only function provided by the NgRx Signals library to update the signal state. It receives as many arguments as possible. The first argument must be the current state; the other arguments are a series of state updaters.

A state updater can be a function that receives the current state and resolves it to a new object, like in our case. Updaters may also be plain objects. Below is another variant of our patchState call that uses a plain object.

patchState(this.randomNumberState, {
  generatedNumbers: [
    randomNumber,
    ...this.randomNumberState().generatedNumbers,
  ],
});

Also, state updaters do not need to update every piece of state. They just need to obtain what they’ll use. For example, if we have some state as shown below:

abcState = signalState<{ a: string; b: string; c: string }>({
  a: "",
  b: "",
  c: "",
});

The patchState calls can look like so for this piece of state:

patchState(this.abcState, { a: "1", b: "2", c: "3" });
// a="1" , b="2", c="3"
patchState(this.abcState, { a: "4" }, (state) => ({ ...state, c = 5 }));
// a="4" , b="2", c="5"

patchState(this.abcState, { a: "100" });
// a="100" , b="2", c="5"

To see our running app, let’s mount our RandomNumberGenerator in our app.component.ts file.

import { Component } from "@angular/core";

import { MovieListComponent } from "../movies/movies-list.component";
import { FormsModule } from "@angular/forms";
import { RandomNumberGeneratorComponent } from "../random-number-generator.component";
@Component({
  selector: "app-root",
  standalone: true,
  imports: [RandomNumberGeneratorComponent],
  template: ` <random-number-generator /> `,
})
export class AppComponent {}

To see the running application, run npm start and head to localhost:4200.

Although our app works as expected, we still have one tiny little fix.

No random numbers are generated by default

Whenever our RandomNumberGenerator component mounts, we want it to have at least one random number.

There are many ways to do it, such as adding a random number in the component’s initial state. In our case, we will use the ngOnInit lifecycle hook to do this. There are several reasons for this choice, such as that in typical applications, we fetch data from a remote server. Also, this will form a basis when we use the lifecycle in the signalStore function in the movie gallery we will be building later.

Once more, let’s now update the random-number-generator.component.ts file.

export class RandomNumberGeneratorComponent implements OnInit {
    randomNumberState = //...omitted for brevity
  noOfRandomNumbers =  //...omitted for brevity
  addRandomNumber(){//...omitted for brevity}


  ngOnInit(): void {
    this.addRandomNumber();
  }
}

Random number generated when the component mounts

Let’s now look at the other variant for setting up our store, which involves using the signalStore function. This function returns a class.

As arguments, it expects a collection of “store features.” They are similar to what we have in a standard Angular component, such as state, methods, lifecycle hooks, etc.

To go into more detail, the list below specifies some of the most common functions the ngrx/signals library provides to add store features.

  • withState(): Allows us to define the state.
  • withMethods(): Used to define the methods and functions for the store.
  • withComputed(): Used to define computed properties based on the state.
  • withHooks(): Allows us to define common lifecycle hooks on the store. Currently, only onInit() and onDestroy() are supported.
  • signalStoreFeature(): Allows us to compose store features. For example, if our app has a store A, this function allows us to use the features of store A in a new store B.

The withMethods, withComputed and withHooks functions all run within the injection context, making it possible to inject other providers using the Angular inject() function. If you need to use any other providers in your app, we will see how to do this later as we proceed.

Also, if you are wondering about the order of calling these functions within the signalStore function, just ensure that state features are added first, as shown below.

signalStore(
  withState()
  //... include the others in any order
);

Let us now proceed to build our movie gallery. We will need a folder called movie. You can create it within the src folder by running this command:

mkdir movie

All terminal commands that will be included from here assume we are in the movie folder we just created.

Define the Store

Run the following command in your terminal to create a folder called movie.store.ts inside a folder called store.

mkdir store
touch store/movie.store.ts

Then update the movie.store.ts file to match the following:

import { computed, Inject, inject } from "@angular/core";
import {
  patchState,
  signalStore,
  signalStoreFeature,
  withComputed,
  withHooks,
  withMethods,
  withState,
} from "@ngrx/signals";

export const MoviesProvider = signalStore();
//..store features will be included here

This file will hold all the logic for our movie store. For now, we just included some of the imports we need. We also created an empty store by calling the signalStore function. Our store does nothing for now; we will update the file as we proceed.

Since we are building a movie app, let’s get some movies. For brevity, we won’t be integrating any third-party API. We will just use some hard-coded data representing the movies. Let’s do that now.

mkdir static
touch static/movies.ts

Now update the movies.ts file with the following:

export type movie = {
  title: string;
  year: number;
  cast: Array<string>;
  genres: Array<string>;
  href: string;
  extract: string;
  thumbnail: string;
  thumbnail_width: number;
  thumbnail_height: number;
};
export const movies: movie[] = [
  {
    title: "The Grudge",
    year: 2020,
    cast: [
      "Andrea Riseborough",
      "Demián Bichir",
      "John Cho",
      "Betty Gilpin",
      "Lin Shaye",
      "Jacki Weaver",
    ],
    genres: ["Horror", "Supernatural"],
    href: "The_Grudge_(2020_film)",
    extract:
      "The Grudge is a 2020 American psychological supernatural horror film",
    thumbnail:
      "https://upload.wikimedia.org/wikipedia/en/3/34/The_Grudge_2020_Poster.jpeg",
    thumbnail_width: 220,
    thumbnail_height: 326,
  },
  //...other movies
];

Next, let’s define a simple service that will mimic API calls to retrieve our hardcoded movies. Create a file called movies.service.ts and add the following to it:

import { Injectable } from "@angular/core";
import { movie, movies } from "../static/movies";
function delay(time: number) {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
}
@Injectable({
  providedIn: "root",
})
export class MovieService {
  async getMovies(): Promise<movie[]> {
    await delay(5000);
    return Promise.resolve(movies);
  }
}

This file exposes a provider that has a getMovies method, and it returns the hard-coded movie state after a 5s delay.

Let’s update the movie.store.ts file:

import { movie } from "../movies/static/movies";

type moviesState = {
  movies: movie[];
  loading: boolean;
  selectedMovies: Map<string, boolean>;
};
const initialState: moviesState = {
  movies: [],
  loading: false,
  selectedMovies: new Map<string, boolean>(),
};
export const MoviesProvider = signalStore(withState(initialState));

Our media gallery’s state consists of an array of movies identified by movies, the loading state boolean we maintain when fetching movies, and a map that holds the selected movies. We defined a variable called initialState and used the withState function to include it in our store.

Adding Lifecycle Hooks

We also need our store to fetch all our movie data when mounted. We will use the onInit lifecycle hook, and we can add this using the withHooks function.

export const MoviesProvider = signalStore(
  withState(https://d585tldpucybw.cloudfront.net/sfimages/default-source/blogs/2024/2024-09//...),
  withComputed(https://d585tldpucybw.cloudfront.net/sfimages/default-source/blogs/2024/2024-09//...),
  withHooks((state) => {
    const moviesService = inject(MovieService);
    return {
      async onInit() {
        patchState(state, { loading: true });
        const movies = await moviesService.getMovies();
        patchState(state, { movies, loading: false });
      },
    };
  }),

As mentioned earlier, the theWithHooks function runs within the injection context allowing us to inject our MovieService provider. The WithHooks function is expected to return an object with one or both of onInit or onDestroy as properties. In our case, we only used the OnInit property which holds a function that will fire when the store provider is initialized. During this initialization, we fetch the movies data invoking getMovies() and store the retrieved it in the state using patchState as usual.

Defining Computed Properties

App displays the total number of movies in gallery and the number of selected movies

Again, part of our movies gallery’s features is to display the number of movies and the number of selected movies. Let’s define some computed signals for that.

export const MoviesProvider = signalStore(
  withState(initialState),
  withComputed((state) => ({
    moviesCount: computed(() => state.movies().length),
    selectedMoviesCount: computed(() => state.selectedMovies().size),
  }))
);

You can also define private computed signals by prefixing the key name with an underscore (_).

_someValue: computed(() => //...some code here)

Again, part of our app feature is to be able to select a movie and delete selected movies. Next, we need to define some methods. Update the movie.store.ts file with the following:

export const MoviesProvider = signalStore(
  withState(https://d585tldpucybw.cloudfront.net/sfimages/default-source/blogs/2024/2024-09//....),
//....other store features
  withMethods((state) => {
    return {
      selectMovieToggle(title: string) {
        const updatedSelected = new Map<string, boolean>(
          state.selectedMovies()
        );
        if (updatedSelected.has(title)) {
          updatedSelected.delete(title);
        } else updatedSelected.set(title, true);
        patchState(state, {
          selectedMovies: updatedSelected,
        });
        this._printMessage('selected movie');
      },


      _printMessage(message: string) {
        console.log(message);
      },


      deleteSelectedMovies() {
        const movies: movie[] = [];
        state.movies().map((movie) => {
          if (!state.selectedMovies().has(movie.title)) {
            movies.push(movie);
          }
        });
        patchState(state, (state) => ({
          ...state,
          movies,
          selectedMovies: new Map(),
        }));
      },
    };
  })
);

The withMethods function expects a callback that gets fed the current state. The callback must return an object whose keys are functions, and the developer can customize the returned functions as desired. Let’s review some of the functions returned in our case.

The selectMovieToggle expects a string that represents the title of the movie, and as the name suggests, this function selects or deselects a movie if it has already been selected. To update the currently selected movies it duplicates the previously selected movies contained in a map. It does so because, as mentioned earlier, the NgRx Signals library embraces immutability, so state update should always create a new instance of a piece of state and not update the state directly. After updating the duplicate selected movie, we update the app state using the patchState function.

Next, the deleteSelectedMovies function deletes the movies that have been selected. Internally, it removes selected movies from the existing movies list and stores the new list in state.

We also included a private method called _printMessaage to remind us that methods prefixed with an underscore are also private.

Let’s now proceed to use our created store in actual components. We will need two components. Assuming you are in the movies folder, run the following commands in your terminal:

touch movie-item.component.ts movie-list.component.ts

Then update the movie-list component with the following:

import { Component, inject } from "@angular/core";
import { MoviesProvider } from "../store/movies.store";
import { MovieItemComponent } from "./movie-item.component";
@Component({
  selector: "movie-list",
  template: `
    <div class="text-center">
      <h1 class="prose-xl mb-8">
        My movies Gallery <b>{{ moviesState.moviesCount() }} movie(s)</b> in
        total
      </h1>
    </div>
    @if (moviesState.selectedMoviesCount()) {
    <div class="text-center sticky top-10 z-[10] bg-white">
      <button
        (click)="moviesState.deleteSelectedMovies()"
        class="btn btn-outline btn-error"
      >
        Delete {{ moviesState.selectedMoviesCount() }} selected movie(s)
      </button>
    </div>
    } @if (moviesState.loading()) {
    <div class="text-center">
      <h1 class="prose-xl mb-8 text-4xl">Loading...</h1>
    </div>
    } @else {
    <div class="max-w-[1440px] mx-auto">
      <div class="grid grid-cols-4  gap-5  items-stretch auto-cols-[3fr]">
        @for (movie of moviesState.movies(); track movie.title) {
        <movie-item
          [movie]="movie"
          [isSelected]="moviesState.selectedMovies().has(movie.title)"
        />
        }
      </div>
    </div>
    }
  `,
  styles: [``],
  standalone: true,
  providers: [MoviesProvider],
  imports: [MovieItemComponent],
})
export class MovieListComponent {
  moviesState = inject(MoviesProvider);
}

Our store was stored in a variable called MoviesProvider. Remember, the signalStore call returns a provider class. To use this provider in our moviesItem component as we do with all Angular providers, we first need to register it in the provider’s array and then inject it in our constructor or use the inject function as we did in our case.

We use the contents of our MovieProvider store—which holds the state, the computed properties and the methods we defined earlier to react to the loading state, render the button to delete selected movies and render the list of movies—using our MovieItem component.

Let’s now proceed to update the movie-item.component.ts file.

import { Component, inject, Input, OnInit } from "@angular/core";
import { MoviesProvider } from "../store/movies.store";
import { FormsModule } from "@angular/forms";
import { MovieService } from "./services/movies.service";
import { movie } from "./static/movies";
@Component({
  selector: "movie-item",
  template: `
    <div
      class="flex flex-col items-center gap-4  justify-between border py-3 px-2 relative"
    >
      <img
        [src]="movie.thumbnail"
        [width]="movie.thumbnail_width"
        [height]="movie.thumbnail_height"
        alt=""
        srcset=""
      />
      <h5 class="font-semibold">{{ movie.title }}</h5>
      <div class="flex gap-2 flex-wrap">
        @for (genre of movie.genres; track $index) {
        <div class="kbd ">{{ genre }}</div>
        }
      </div>
      <input
        type="checkbox"
        class="checkbox"
        id="name"
        (change)="handleSelectmovie($event)"
        name="name"
        [checked]="isSelected"
      />
    </div>
  `,
  styles: [``],
  standalone: true,
  imports: [FormsModule],
})
export class MovieItemComponent {
  @Input({ required: true }) movie!: movie;
  @Input() isSelected!: boolean;
  movieState = inject(MoviesProvider);
  handleSelectMovie(e: any) {
    this.movieState.selectMovieToggle(this.movie.title);
  }
}

The MovieItemComponent expects two properties as inputs: the movie item and a boolean specifying if the movie is selected. This component also injects the MovieProvider, and we bind a handleSelectMovie function to the check box to select or deselect the movie.

Decoupling State

When building applications, we typically have situations where multiple entities share an exact piece of state. In our case, our movies component maintains a loading state that is used as a flag for the state of the data-fetching operation. We may also be fetching the user’s profile in our app, which also needs a loading state. The signalStoreFeature function allows us to share and reuse stateful data, hooks, etc., across store instances.

Let’s see an example of how to decouple the loading state. We will be updating our movie.store.ts file.

const withLoadingFeature = () => {
  return signalStoreFeature(
    withState<{ loading: boolean }>({
      loading: false,
    }),
    withComputed((state) => ({
      status: computed(() => (state.loading() ? "loading" : "success")),
    }))
  );
};
function setLoading() {
  return { loading: true };
}
function stopLoading() {
  return { loading: false };
}

In the snippet above, we created a function called withLoading that uses the signalStore function to maintain state using withState and some computed signals using withComputed.

We also defined two functions that are just state updaters: setLoading and stopLoading. They return objects that modify the loading state. We defined the updaters like this so they are reusable in other stores that consume the withLoadingFeature feature.

We can now plug in our new store feature in our movie signal store.

import { computed, Inject, inject } from '@angular/core';
import {
  patchState,
  signalStore,
  signalStoreFeature,
  withComputed,
  withHooks,
  withMethods,
  withState,
} from '@ngrx/signals';
import { MovieService } from '../movies/services/movies.service';
import { filter } from 'rxjs';
import { movie } from '../movies/static/movies';
const withLoadingFeature = //...

type moviesState = {
  movies: movie[];
  loading: boolean;
  selectedMovies: Map<string, boolean>;
};
const initialState: moviesState = {
  movies: [],
  loading: false,
  selectedMovies: new Map<string, boolean>(),
};
export const MoviesProvider = signalStore(
  withState(initialState),
  withComputed(https://d585tldpucybw.cloudfront.net/sfimages/default-source/blogs/2024/2024-09//...omitted for brevity),
  withHooks((state) => {
    const moviesService = inject(MovieService);
    return {
      async onInit() {
        patchState(state, setLoading());
        const movies = await moviesService.getMovies();
        patchState(state, { movies }, stopLoading());
      },
    };
  }),
  withMethods(https://d585tldpucybw.cloudfront.net/sfimages/default-source/blogs/2024/2024-09//...omitted for brevity),
  withLoadingFeature()
);

By doing this, our MoviesProvider variable, together with its state, computed properties and methods, also includes additional loading and status properties provided by the withLoadingFeature. These properties can be used in components as shown below.

@Component({
  selector: "movie-list",
  template: `
    //...other code omitted for brevity @if (moviesState.loading()) {
    <div class="text-center">
      <h1 class="prose-xl mb-8 text-4xl">{{ moviesState.status() }}</h1>
    </div>
    } //...other code omitted for brevity
  `,
})
export class MovieListComponent implements OnInit {
  moviesState = inject(MoviesProvider);
}

Conclusion

Effective state management is important for building scalable and responsive applications. This guide covers another approach to doing this with signals.


About the Author

Christian Nwamba

Chris Nwamba is a Senior Developer Advocate at AWS focusing on AWS Amplify. He is also a teacher with years of experience building products and communities.

Related Posts

Comments

Comments are disabled in preview mode.