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({
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({
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.
Click here to reveal solution
Solution
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.