3d cartoon hands holding a phone

Unlock full course by purchasing a membership

Lesson 2

Simple State Management in Angular

A basic approach to state (and where it fails)

STANDARD

Simple State Management in Angular

We can broadly consider two different types of state in our application: local state and shared/global state. Local state is state that is isolated within a single location, e.g. within a single component. Shared state is state that needs be to be shared among one or more different parts of the application, e.g. with multiple components. Often shared state is made globally accessible to any part of the application, but it is also possible to restrict the sharing of state to specific features/modules.

We have already seen an example of local state with our original example:

@Component({
    standalone: true,
    selector: `app-home`,
    template: `
      @if (showGreeting()){
          <app-greeting />
      }

      <button (click)="toggleGreeting()">Toggle</button>
    `
})
export class HomeComponent {
    showGreeting = signal(false);

    toggleGreeting(){
        this.showGreeting.update((showGreeting) => !showGreeting)
    }
}

As long as only this HomeComponent needs to know about the current showGreeting value it can just remain as local state that only this component has access to, i.e. it can just be a class member on the component itself. We can even use this same technique in other components — also displaying an app-greeting tied to a showGreeting boolean — but as long as they all maintain their own values for showGreeting and don’t care about what the showGreeting value is set as in other components it is local state.

NOTE: Although local state can just be added directly to the class of a component, it is common to still create a service to hold the state and provide that service to just that one component (instead of using providedIn: 'root'). The purpose of the service in this case is not to share the state, but just as a separate place where we can keep our state information to keep things more organised.

To give you a sense of a slightly more advanced and more realistic usage of local state, here is a snippet from one of the applications we will be building:

export default class ChecklistComponent {
  checklistService = inject(ChecklistService);
  checklistItemService = inject(ChecklistItemService);
  route = inject(ActivatedRoute);

  checklistItemBeingEdited = signal<Partial<ChecklistItem> | null>(null);

  params = toSignal(this.route.paramMap);

  items = computed(() =>
    this.checklistItemService
      .checklistItems()
      .filter((item) => item.checklistId === this.params()?.get('id'))
  );

  checklist = computed(() =>
    this.checklistService
      .checklists()
      .find((checklist) => checklist.id === this.params()?.get('id'))
  );
}

We are using signals to deal with the simple local state this component requires. We want to keep track of the checklistItemBeingEdited so we have a signal for that — it will either be the checklist item, or it will be null. We need the params information from the ActivatedRoute so we create a signal for that too. We create another bit of local state to store all of the items for the particular checklist we have just routed to (we use our paramMap to derive this). We also have another signal to store the specific checklist that is active in the component.

With these signals, we now have state for:

  • The checklist item being edited
  • The current route parameters
  • The items for the active checklist
  • The active checklist itself

The more complex state for this component is stored within the ChecklistItemService which is a service dedicated to managing this component’s state. It is not shared with other components. We will take a look at what that sort of state looks like in a moment.

Managing Complex State

What we have discussed so far works quite well for a while.

Need local component state? Use a signal in the component to represent that state:

@Component({
    standalone: true,
    selector: `app-home`,
    template: `
      @if (showGreeting()){
          <app-greeting />
      }

      <button (click)="toggleGreeting()">Toggle</button>
    `
})
export class HomeComponent {
    showGreeting = signal(false);

    toggleGreeting(){
        this.showGreeting.update((showGreeting) => !showGreeting)
    }
}

Need shared state? Create a service with a signal:

import { Injectable, signal } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class PreferenceService {
  #showGreeting = signal(false);
  showGreeting = this.#showGreeting.asReadonly();

  toggleGreeting() {
    this.#showGreeting.update((showGreeting) => !showGreeting);
  }
}

You can take this approach reasonably far and have something that is reasonably simple and scalable. The real problem occurs when you start needing to deal with asynchronous code — things like HTTP requests, or just observables in general, or Promises, or anything else asynchronous.

This all goes back to declarative code. We discussed this earlier, but the general idea was that if our code is declarative then we can understand what something is — now and at any point during the lifetime of our applications — just by looking at its declaration.

Let’s consider the local state example we just looked at:

export default class ChecklistComponent {
  checklistService = inject(ChecklistService);
  checklistItemService = inject(ChecklistItemService);
  route = inject(ActivatedRoute);

  checklistItemBeingEdited = signal<Partial<ChecklistItem> | null>(null);

  params = toSignal(this.route.paramMap);

  items = computed(() =>
    this.checklistItemService
      .checklistItems()
      .filter((item) => item.checklistId === this.params()?.get('id'))
  );

  checklist = computed(() =>
    this.checklistService
      .checklists()
      .find((checklist) => checklist.id === this.params()?.get('id'))
  );
}

Take a moment to consider if this is declarative or not. Is it entirely declarative? Partially declarative? What parts are declarative? What parts aren’t declarative?

It doesn’t matter if you don’t get it, just start thinking about it. It takes a while for these concepts to really click, and realistically they won’t click until you’ve at least built a few applications using these concepts.

This is mostly declarative — in fact it is very much declarative enough, I would not strive to make this any more declarative than it already is. And this is all achieved with just signals for state management.

The only thing that is not declarative is this:

  checklistItemBeingEdited = signal<Partial<ChecklistItem> | null>(null);

Whenever we have something that has to be manually set from somewhere it is imperative. Some code at some point needs to set this signal, it does not just derive its value automatically from events in the application (or outside of the application) occurring.

Everything else in that snippet is derived from something else, it is never manually set, it gets automatically updated. Therefore, we can understand all the ways they can change just by looking at their declarations.

This idea of using some imperative code at the beginning of a reactive/declarative flow — we start with something imperative, but everything after that is declarative — is an idea we will use a lot. There is nothing wrong with this code.

However, we are only dealing with synchronous reactivity at this point. Everything can be calculated immediately and so signals suit this task well.

But, suppose that this code:

  checklist = computed(() =>
    this.checklistService
      .checklists()
      .find((checklist) => checklist.id === this.params()?.get('id'))
  );

…can not be synchronous. Instead of being able to load local data from the ChecklistService we might need to load this checklist data from an API. That would make this code asynchronous. We might do something like this with signals:

  checklist = signal<Checklist | null>(null);

  ngOnInit() {
    this.http
      .get('someapi')
      .subscribe((checklist) => {
        this.checklist.set(checklist);
      });
  }

You can do this, but it makes the code less declarative. We’re adding another signal instead of deriving the value in some way. We have this extra imperative code in the ngOnInit that needs to be triggered and it sets the signal once the request is completed.

NOTE: The subscribe to an observable in the “bad” example above would also ideally require adding some mechanism to unsubscribe.

We’re dealing with asynchronous code now, and signals just can’t deal with asynchronous code in a declarative way. We need to bring in RxJS to handle asynchronous reactivity if we want to keep things declarative.

We could do something like this:

  checklist$ = this.http.get('someapi');
  checklist = toSignal(this.checklist$);

We use an RxJS observable to handle the asynchronous HTTP request, and then we can still convert the result into a signal to use in our template or wherever else we need it.

This is completely declarative now which has its own benefits, but this code is also clearly a lot simpler — no need for all of that imperative code in ngOnInit and no need to worry about handling the subscription.

So, using RxJS and signals together is desirable. The example above is neat and tidy enough, but things won’t always be so simple. We want a way to handle state in this manner that is going to be scalable and predictable as it grows in complexity. We want to be able to jump into a codebase we aren’t familiar with — whether it is our own codebase we wrote 6 months ago, or a completely new codebase — and be able to quickly understand what is going on and how we should integrate new code in a way that others will also understand.

So, how should we deal with integrating RxJS and Signals? There are many ways you can go about it — there are ways you could go about it that are declarative and ways that aren’t. We will take a look at that in the next lesson.