Angular Signals – When should I use what?

In my last article, I explained how our communication with the backend takes place and how we were able to build a very simple caching mechanism thanks to signals. In this blog post, I would now like to demonstrate how we use the signals from our services within the components.

Views

As mentioned in the previous post, we have, broadly speaking, “Views” and “Actions” for displaying data from the backend. In the Views, primarily only data is presented, and in Actions, this data is edited, upon which a command (CQS) is triggered, and the Action Panel closes.

Since Views are intended to display only data, our preferred approach is, of course, either the direct propagation of the signal from the service:

@Component({
  selector: 'todos-view',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    {{ todos() | json }}
  `,
})
export class TodosView {
  constructor(private todoService: TodoService) {
    this.todos = this.todoService.getTodos();
  }
  todos: Signal<Todo[]>;
}

or, when different data needs to be merged together, the use of computed(...):

export class TodosView {
  constructor(
    private todoService: TodoService,
    private usersService: UsersService
  ) {
    this.todos = computed(() => {
      const todos = this.todoService.getTodos()();
      const users = this.usersService.getAllUsers()();

      if (!todos || !users) {
        return [];
      }

      return todos.map<AugmentedTodo>((t) => {
        const user = users.find((u) => u.id === t.id)!;
        return {
          id: t.id!,
          completed: t.completed,
          title: t.title,
          username: user.username,
        };
      });
    });
  }
  todos: Signal<AugmentedTodo[]>;
}

export interface AugmentedTodo {
  id: number;
  username: string;
  title: string;
  completed: boolean;
}

But what if an update button needs to be present? The idea behind this is that while the myService.onChange signal gets next(...)ed after performing an action on myService, if another user in a different browser performs the same action, the onChange won’t be triggered for the first user, and therefore the data won’t be updated (using technologies like SignalR are out of scope for this). In our system, this is often not a problem, but it would still be nice to give the user an option to reload data for a view.

For this purpose, we typically set up a new signal on the component, which serves only as a loading trigger:

@Component({
  selector: 'todos-view',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    {{ todos() | json }}
    <button type="button" (click)="reload()">Reload</button>
  `,
})
export class TodosView {
  constructor(
    private todoService: TodoService,
    private usersService: UsersService
  ) {
    effect(
      () => {
        const _ = this.trigger();
        this.todoService.forceReload();
        this.usersService.forceReload();
      },
      { allowSignalWrites: true }
    );

    this.todos = computed(() => {
      const todos = this.todoService.getTodos()();
      const users = this.usersService.getAllUsers()();

      if (!todos || !users) {
        return [];
      }

      return todos.map<AugmentedTodo>((t) => {
        const user = users.find((u) => u.id === t.id)!;
        return {
          id: t.id!,
          completed: t.completed,
          title: t.title,
          username: user.username,
        };
      });
    });
  }
  todos: Signal<AugmentedTodo[]>;
  trigger = signal(createGuid());

  reload() {
    this.trigger.set(createGuid());
  }
}

As you can see, in the effect(...) function, the trigger is being subscribed, but we’re not actually interested in the value it returns at all. This is because the value is simply a GUID, where the only thing that matters is that it’s different from the last GUID (a simple counter would likely suffice as well). For example, if the same string were set in the set function every time, the trigger in the effect(...) function wouldn’t activate, as the value itself hasn’t changed. As soon as the effect(...) gets executed, it forces the two sources to reload. Keep in mind that we need to specify { allowSignalWrites: true } because forceReload() triggers the internal reload-signals within the respective services.

Loading Indicator

Let’s now take a step further and display a loading animation while the data is being fetched. For this scenario, we have an additional field in the component called isLoading. There are now two variations on how we can implement this:

computed

We still stick with the computed(...) approach and set isLoaded to true before we actually fetch our data and set it to false right before we return the data. However, each time after we set isLoaded to a new value, we also need to trigger changeDetector.markForCheck() because we have been diligent and, of course, set the changeDetection to OnPush on our View Component:

@Component({
  selector: 'todos-view',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div *ngIf="!isLoading; else loadingIndicator" class="main">
      {{ todos() | json }}
      <button type="button" (click)="reload()">Reload</button>
    </div>
    <ng-template #loadingIndicator>loading...</ng-template>
  `,
})
export class TodosView {
  constructor(
    private todoService: TodoService,
    private usersService: UsersService,
    changeDetectorRef: ChangeDetectorRef
  ) {
    effect(
      () => {
        const _ = this.trigger();

        this.isLoading = true;
        changeDetectorRef.markForCheck();

        this.todoService.contentChanged();
        this.usersService.contentChanged();
      },
      { allowSignalWrites: true }
    );

    this.todos = computed(() => {

      const todos = this.todoService.getTodos()();
      const users = this.usersService.getAllUsers()();

      if (!todos || !users) {
        this.isLoading = false;
        changeDetectorRef.markForCheck();
        return [];
      }

      const result = todos
        .map<AugmentedTodo>((t) => {
          const user = users.find((u) => u.id === t.userId)!;
          return {
            id: t.id!,
            completed: t.completed,
            title: t.title,
            username: user.username,
          };
        });

      this.isLoading = false;
      changeDetectorRef.markForCheck();

      return result;
    });
  }
  todos: Signal<AugmentedTodo[]>;
  trigger = signal(createGuid());
  isLoading = false;

  reload() {
    this.trigger.set(createGuid());
  }
}

effect

The alternative is to use an effect(...) and forgo markForCheck. We achieve this by also turning isLoaded into a signal. augmentedTodo remains a signal as well, and since we’re setting other signals within an effect(...), we need to communicate this to the effect using allowSignalWrites: true:

@Component({
  selector: 'todos-view',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
  <div *ngIf="!isLoading(); else loadingIndicator" class="main">
      {{ todos() | json }}
      <button type="button" (click)="reload()">Reload</button>
    </div>
    <ng-template #loadingIndicator>loading...</ng-template>
  `,
})
export class TodosView {
  constructor(
    private todoService: TodoService,
    private usersService: UsersService
  ) {
    effect(
      () => {
        const _ = this.trigger();
        this.isLoading.set(true);
        this.todoService.contentChanged();
        this.usersService.contentChanged();
      },
      { allowSignalWrites: true }
    );

    effect(
      () => {
        const todos = this.todoService.getTodos()();
        const users = this.usersService.getAllUsers()();

        if (!todos || !users) {
          this.isLoading.set(false);
          this.todos.set([]);
          return;
        }

        const result = todos
          .map<AugmentedTodo>((t) => {
            const user = users.find((u) => u.id === t.userId)!;
            return {
              id: t.id!,
              completed: t.completed,
              title: t.title,
              username: user.username,
            };
          })
          .slice(0, 10);

        this.isLoading.set(false);
        this.todos.set(result);
      },
      { allowSignalWrites: true }
    );
  }

  todos = signal<AugmentedTodo[]>([]);
  trigger = signal(createGuid());
  isLoading = signal(true);

  reload() {
    this.trigger.set(createGuid());
  }
}

What do we prefer?

Personally, I prefer option two, as option one essentially goes against the principles of the computed(...) function: a computed(...) should accumulate data and then return a result from it; it shouldn’t have side effects. In contrast, the effect(...) function, as its name suggests, inherently involves side effects.

Actions

When presenting data within an action, it typically comes from either a backend service (communication between frontend and backend), a component communication service (communication from component to component), or through the URL, which is our preferred approach. To achieve this, we encode the data —typically already loaded and displayed in the view— into a Base64 string and include it as a parameter in the URL. Utilizing auxiliary routes, we can navigate to the main view separately from the action panel via a route, enabling us to open or close an action panel using only the navigate(...) method on router.

However, since ActivatedRoute has not yet been adapted to signals, activatedRoutes.params provides us with an Observable. We first transform this using toSignal(…), which we then unpack inside an effect(...). There, the Base64 string is decoded back into a JS object, allowing us to feed the individual fields that can be edited within the action. This time, however, we need to trigger a changeDetector.markForCheck() at the end of our effect(...), as we are populating plain fields and not signal fields. Why are we using plain fields? Because it’s easier to manipulate the value of plain fields using ngModel compared to using ngModel in conjunction with signals. It’s very possible that in a future Angular version (at the time of writing, Angular 16 is being used), the integration between ngModel and signals will be enhanced, and at that point, we might use a computed(...) instead of an effect(...). But for now, this is the best approach for us.

@Component({
  selector: 'update-action',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
  <input type="text" [(ngModel)]="title" name="title" />
  <input type="checkbox" [(ngModel)]="completed" name="completed" />
  <button (click)="edit()">Edit</button>
  `,
})
export class TodoUpdateAction {
  id: number;
  title: string;
  completed: boolean;

  constructor(
    activatedRoute: ActivatedRoute,
    private todoService: TodoService
  ) {

    const params = toSignal(activatedRoute.params);
    effect(() => {
      const todo = decode<Todo>(params()?.data);
      this.id = todo.id;
      this.title = todo.title;
      this.completed = todo.completed;
    });
  }

  async edit() {
    await this.todoService.updateTodo(this.id, { title: this.title, completed: this.completed });
  }
}

Now it’s your turn: Are we using signals completely wrong, or were you able to learn one or two things and adapt them for yourselves? And would you be interested in reading about how exactly we transfer data from the view to the actions? Let me know in the comments!

About the author

Domenic Helfenstein

Add comment

Recent Posts