State Management with Nested Signals (Experimental)

Published 10-25-2023

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.

This may not practically scale for all scenarios. However, 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

Let’s break it down

For complete code & styling, see 🚀 Full Source Code

todo thumbnail

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

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 Advanced Signals

Wed Oct 25 2023