Angular Advanced Signals

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.

Article Preview
// → 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.

  • Simplified reactivity model
  • Fine grain reactivity & performance
  • Lower learning curve via small API
Introduction to Signals

If you’re not familiar with Angular signals see these previous articles

Signals that Set Signals

To power a signal-based architecture, we need the ability for signals to update other signals. Similar to the way:

  • A RxJS observable might trigger an update to a store/subject
  • A Svelte reactive statement might trigger updates to a writable store
  • A SolidJS resource uses a signal to trigger an API to populate a signal

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
  }
})

Breaking Down Signals Write Error

Error: NG0600:

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.

A Recursive Analogy

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
}

The Solution

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

  • Use untracked which prevents the signal from being added as a dependency
  • Using async / await, signals are not added as dependencies after the await. (More on this later)

Effect vs Computed

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!']) 
}

Advanced Signals in Action

🚀 Source Code Example

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.

  • Thanks to Alex from the Angular team for helping me understand this :)

Putting it Together

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


Review

In this specific example we can point out some hypothetical improvements over rxjs

  • We don’t have to use multiple async pipe (or wrap in a vm$ observable)
  • Our effect is effortlessly optimized to only fire when the activeId changes
    • e.g. Calling addNote doesn’t accidentally call loadNotes
  • The surface area of the signal API is small. 0 additional libraries.
  • Signals are automatically cleaned up on destroy
  • Signals are used the same way in and out of the template
  • We could scale this example using fine-grain reactivity

Read the rest of the series here.

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.

📡 Angular Signal Effects

Mon Oct 23 2023