Published 10-23-2023
Frameworks like Svelte
& SolidJS
use simple reactivity models that are a joy to work with. They take a lot of the complexity out of writing performant predictable reactive code, without installing 3rd party libraries.
Angular Signals opens up opportunities to create a similar experience.
// → 100% Signal Based 📡
notebooks = signal({
active: null,
list: MOCK_NOTEBOOKS,
});
activeId = computed(() => this.notebooks.active)
notes = signal([]);
loadingNotes = signal(false);
// → Simplified Event Handling ✅
loadNotes = effect(() => {
if (this.active_id()) {
// → Optimized Loading! 🚀
untracked(() => this.load())
}
});
async load() {
// → Easy Reactive API Usage! 🛜
this.loadingNotes.set(true)
const result = await long_fake_API(this.active_id(), 2000);
this.notes.set(result);
this.loadingNotes.set(false)
}
Using advanced applications of signals, we can start to imagine what a primarily-signal-driven architecture might look like. There’s a lot of possible benefits to this approach, albeit hypothetical.
If you’re not familiar with Angular signals see these previous articles
To power a signal-based architecture, we need the ability for signals to update other signals. Similar to the way:
However, we need to clarify on some behavior when updating signals
inside of effect
. Let’s start with an example. This snippet will result in an error (which is a good thing).
activeNotebookId = signal('123')
notes = signal([])
effect(() => {
if (activeNotebookId) {
const result = getNotesById(activeNotebookId);
// Error!
notes.set(result); // → Writable Signal
}
})
Error: NG0600: Writing to signals is not allowed in a computed or an effect by default. Use allowSignalWrites
in the CreateEffectOptions to enable this inside effects.
For context, what happens when we don’t define a base case to a recursive function? Infinite loop → Stack overflow
This doesn’t mean we shouldn’t write recursive functions. It means we need to make sure we define a base case to exit the function calling itself.
function foo(n: number): number {
if (n === 1) { // Base Case
return 1; // → You need this or 💥
}
return n + foo(n - 1); // Recurse
}
When we reference a signal inside of effect
(or computed
) it will add that signal as a dependency. The problem with setting a signal inside of effect
is that it will create an infinite loop.
Signal updated > Dependency changed > Run effect > Signal updated > Dependency changed > ...
However, I find the error misleading. Because its an extreme solution where two reasonable solutions already exist
untracked
which prevents the signal from being added as a dependencyasync
/ await
, signals are not added as dependencies after the await
. (More on this later)Lastly, why not use computed
and avoid the write altogether? Computed creates a Readable
signal. Which is ideal for most use cases. Being able to directly write to doubled
doesn’t make sense and should be updated indirectly via count
. But for something like setting a source of data (as a signal), it fails when we update that signal outside of computed.
// Makes sense
count = signal(1)
doubled = computed(() => count * 2)
// Errors
id = signal(1)
// → Get notes when id changes
notes = computed(async () => {
const result = await loadNotesFromAPI(this.id())
return result;
})
addNewNote() {
// Error: Readable Signal!
this.notes.update(n => [...n, 'New Note!'])
}
Let’s imagine a scenario where changes to a signal invoke set
on a second writable signal. - We define an array of Notebooks. Each notebook has a array of Notes.
If this information was static, computed
might work. However, we want to pull initial data from an API and give the user the ability to add new notes via user interaction.
We could define our signals, setters, and a (basic) template like this
notebooks = signal({
active: null,
list: MOCK_NOTEBOOKS,
});
activeId = computed(() => this.notebooks.active)
notes = signal([]);
setActiveNotebook(id) {
this.notebooks.update((n) => ({ ...n, active: id }));
}
addNote() {
this.notes.update(n => [...n, "My new note!"])
}
<button
*ngFor="let item of notebooks().list"
(click)="setActiveNotebook(item.id)"
[class.highlight]="notebooks().active === item.id">
{{item.id}}
</button>
<div *ngFor="let note of notes()">
{{note}}
</div>
<ng-container *ngIf="activeId()">
<button (click)="addNote()">Add Note</button>
</ng-container>
To wire up our feature. All we need is an effect
to update notes
when the activeId
changes. In our first example, we’ll omit an API call for a plain old javascript object.
It’s also vital that we wrap our setter in untracked
, otherwise we throw the allowSignalWrites
error (understandably so).
activeId = computed(() => this.notebooks().active);
loadNotes = effect(() => {
if (this.activeId()) {
const result = MOCK_NOTES_BY_ID[this.activeId()]
untracked(() => this.notes.set(result))
}
})
Now let’s try something a little more practical. This time we’ll pull data from a (fake) API to populate the notes.
Notice we’ve removed untracked
. Surprisingly, we do not need untracked here (although it doesn’t hurt to use).
activeId = computed(() => this.notebooks().active);
loadNotes = effect(async () => {
if (this.activeId()) {
const result = await long_fake_API(this.activeId())
this.notes.set(result)
}
})
The effect essentially stops tracking dependencies past the first await. This is because async functions behave like normal functions until hitting await. They then register a callback with the code being awaited. Thus everything after the await is effectively untracked. i.e. Code after the await is technically not apart of effects callback, so it won’t track dependencies referenced there.
Taking it one step further. Let’s add another signal(boolean) loadingNotes
to track if the API is being called and review the completed example.
// Complete Example
notebooks = signal({
active: null,
list: MOCK_NOTEBOOKS,
});
activeId = computed(() => this.notebooks.active)
notes = signal([]);
// → Add signal to track loading state
loadingNotes = signal(false);
loadNotes = effect(() => {
if (this.active_id()) {
untracked(() => this.load())
}
});
// → Extract logic to load function
async load() {
this.loadingNotes.set(true)
const result = await long_fake_API(this.active_id(), 2000);
this.notes.set(result);
this.loadingNotes.set(false)
}
setActiveNotebook(id) {
this.notebooks.update((n) => ({ ...n, active: id }));
}
addNote() {
this.notes.update(n => [...n, "My new note!"])
}
💡 We brought untracked
back since this.loadingNotes.set(true)
is called before the await
In this specific example we can point out some hypothetical improvements over rxjs
vm$
observable)activeId
changes
addNote
doesn’t accidentally call loadNotes
Read the rest of the series here.
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.