State Management with Nested Signals (Experimental)
Experimental Angular state management with nested signals
// Article Preview
export class FineGrainComponent {
hideCompleted = signal(false);
// --> Easy to setup, no additional libraries ✅
todoList = signal<Todo[]>([
{title: 'Write article', complete: signal(true)},
{title: '????', complete: signal(false)},
{title: 'Profit!!!', complete: signal(false)},
])
// --> computed reacts to nested signal ✅
filteredList = computed(() => !this.hideCompleted() ?
this.todoList() : this.todoList().filter(t => !t.complete())
)
updateTodo(complete: WritableSignal<boolean>) {
// --> clean & efficient array updates ✅
complete.update(c => !c)
}
}
This is a todo list component. Notice the complete
property. It’s a signal, inside of a signal. Nested signals (fine-grain reactivity) can unlock some interesting patterns. Such as simplifying updates to arrays & lists.
For a simple todo component this creates a really convenient and interesting approach to reactivity. To be clear, this does work in Angular 16+. But, this may not practically scale for all scenarios.
Frameworks like SolidJS implement conveniences to make nested signals practical. It would be exciting to see if the Angular team is considering adding solutions like SolidJS’s store pattern. As they’ve been working with the creator of SolidJS on signals.
If you’re not familiar with Angular signals see these previous articles
👉 Angular Signals in 3 Minutes
Let’s break it down
For complete code & styling, see 🚀 Full Source Code
Filters & Fine Grain Reactivity
hideCompleted
is a signal powered toggle. When activated, the computed signal will filter out the todos with complete: true
. Since complete()
is defined in our computed
any update to each individual complete
will rebuild our filteredList
.
hideCompleted = signal(false);
toggleCompleted() {
this.hideCompleted.update(c => !c)
}
filteredList = computed(() => !this.hideCompleted() ?
this.todoList() :
this.todoList().filter(t => !t.complete())
)
<label for="switch">
<input type="checkbox" name="switch" role="switch"
[checked]="hideCompleted()"
(change)="toggleCompleted()" />
Hide Complete
</label>
Nested Updates & Loops
We’ll iterate over our computed
signal to build our todo list. Notice how we type, access, and set the complete
value.
updateTodo(complete: WritableSignal<boolean>) {
complete.update(c => !c)
}
<!-- Iterate over computed & access nested signal via `()` -->
<div *ngFor="let todo of filteredList()">
<label for="checkbox">
<input type="checkbox" name="checkbox"
[checked]="todo.complete()"
(change)="updateTodo(todo.complete)" />
{{todo.title}}
</label>
</div>
Each complete
is bound to a checkbox. Using nested signals:
- Each signal is available for directly getting/setting.
- Updates to our nested signals will be reflected in the
computed
- We’ve removed the need to lookup the item in the source array by id or index.
- Looking up by index can be tricky, as we’re using a filtered list
- Looking up by id adds an unnecessary searching or restructuring of the source array
Creating New Nested Signals
addTodo(title: string) {
this.todoList.update(v => [...v, {title, complete: signal(false)}])
}
<form (ngSubmit)="addTodo(title.value)">
<input #title type="text" name="addtodo"/>
<button type="submit">+Add Todo</button>
</form>
To create a new todo, we update our source signal with a new object. We create another signal, nested inside of that object.
Conclusion / Performance
In a framework like SolidJS, the entire list won’t have to re-render when the complete
property changes. Only the row with the changed complete
re-renders. Hence, “fine-grain reactivity”.
From my testing, this didn’t seem to be the case with Angular. It seemed like the loop was still re-rendered. But, I could have made a mistake in my testing or needed to implement trackBy
a certain way to make this work.
For a small simple component this provides some interesting DX
- Computed just works, no issues with mutability or the way we trigger updates to
complete
- No lookups to our array, slice logic, or creating a separate indexed object
- Creating, typing, getting, and setting nested signals is just as simple as individual signals
It would be interesting to see if the Angular team creates conveniences around this (Like SolidJS Store).
Complete Example
import { Component, WritableSignal, computed, signal } from '@angular/core';
interface Todo {
title: string;
complete: WritableSignal<boolean>;
}
@Component({
selector: 'app-fine-grain',
templateUrl: './fine-grain.component.html',
styleUrls: ['./fine-grain.component.css'],
})
export class FineGrainComponent {
hideCompleted = signal(false);
todoList = signal<Todo[]>([
{ title: 'Refactor entire app', complete: signal(true) },
{ title: '????', complete: signal(false) },
{ title: 'Profit!!!', complete: signal(false) },
]);
filteredList = computed(() =>
!this.hideCompleted()
? this.todoList()
: this.todoList().filter((t) => !t.complete())
);
updateTodo(complete: WritableSignal<boolean>) {
complete.update((c) => !c);
}
toggleCompleted() {
this.hideCompleted.update((c) => !c);
}
addTodo(title: string) {
this.todoList.update((v) => [...v, { title, complete: signal(false) }]);
}
}
More Angular Signals
Enjoyed this article? Content similar to this is available on Flotes as studyable Notebooks. Information is delivered like Anki-style flashcards. Allowing you to fill in blanks and evaluate difficultly, to maximize learning efficiency.
Supercharge & streamline your notes. Flotes is a Markdown note taking app that organizes and prioritizes your notes for learning new things.
Originally published at https://blog.flotes.app.