At TechStackNation, Alex Rickabaugh showcased a Signals reset pattern that clears child signals whenever their parent signals change.

The approach leverages computed signals to perform updates synchronously, and an upcoming Angular release may introduce a new function to streamline it even further.

Alex Rickabaugh, who leads the Angular framework team, presented a reset pattern for Signals during his TechStackNation appearance.

His demo illustrated two Signal groups—parents and children—where parent changes trigger a reset of the children, but child updates leave the parent group untouched.

The technique he shared relies on a computed Signal that wraps nested Signals to implement this reset behavior.

This pattern proves valuable in scenarios such as input functions, where you need a read-only signal that updates internally yet remains in sync with incoming parent changes.

Despite effects being able to replicate the outcome, computed signals provide immediate updates, sidestepping the race conditions that can arise from effect-driven asynchronous synchronization.

An "effect-based" implementation would look like:

interface Product {
  id: number;
  name: string;
}

@Component({
  selector: 'app-basket',
  template: `
    <p>Selected Product: {{ product().name }}</p>

    <input [(ngModel)]="amount" name="amount" />
    <button mat-raised-button>Add to Basket</button>
  `,
  standalone: true,
  imports: [FormsModule, MatButton, MatInput],
})
export class BasketComponent {
  // parent signal
  product = input.required<Product>(); // <-- parent signal

  // child signal
  amount = signal(0);

  resetEffect = effect(() => {
    this.product();
    untracked(() => {
      this.amount.set(0);
    });
  });
}

@Component({
  selector: 'app-basket-container',
  template: `
    <div class="gap-x-2 mb-5 flex">
      <button mat-raised-button (click)="previousProduct()" [disabled]="productIx() === 0">←</button>
      <button mat-raised-button (click)="nextProduct()" [disabled]="productIx() >= products.length - 1">→</button>
    </div>
    <app-basket [product]="selectedProduct()"></app-basket>`,
  standalone: true,
  imports: [BasketComponent, MatButton],
})
export class BasketContainerComponent {
  protected readonly products = [
    { id: 1, name: 'Apple' },
    { id: 2, name: 'Banana' },
    { id: 3, name: 'Orange' },
  ]

  protected productIx = signal(0);
  protected selectedProduct = computed(() => this.products[this.productIx()]);

  nextProduct() {
    this.productIx.update((value) => value + 1);
  }

  previousProduct() {
    this.productIx.update((value) => value - 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Employing the "reset pattern" alongside computed means the BasketComponent must be set up as follows:

@Component({
  selector: 'app-basket',
  template: `
    <p>Selected Product: {{ product().name }}</p>

    <input [(ngModel)]="state().amount" name="amount" />
    <button mat-raised-button>Add to Basket</button>
  `,
  standalone: true,
  imports: [FormsModule, MatButton, MatInput],
})
export class BasketComponent {
  product = input.required<Product>();

  state = computed(() => ({
    product: this.product(),
    amount: signal(0),
  }));
}

Enter fullscreen mode Exit fullscreen mode


Pawel Koszlowski mentioned that Angular's team may introduce a dedicated utility to handle this pattern, perhaps as early as Angular 19, though the details are still under discussion.

GitHub logo Add WritableComputed to allow computed with write operations #55673

Harpush avatar
Harpush posted on

core

Description

A revival of the use case outlined in #50498

Right now, signal gives you a writable signal, while computed yields a signal that cannot be written. What’s absent from the picture is a writableComputed.

Here’s the scenario that calls for it: a required input named value exists, yet the component should permit local edits without leaking them to the parent. Only when the user clicks an apply button does the component push the updated value through an output. The implementation would look along these lines:

class Test {
  value = input.required<string>();
  tempValue = signal(/*??*/);
  commit = output<string>()

  onEdit(newValue: string) {
    this.tempValue.set(newValue);
  }

  onApply() {
    this.commit.emit(this.tempValue());
  }
}
Enter fullscreen mode Exit fullscreen mode

A couple of issues arise here. To start with, tempValue doesn't derive from value. Then, if value gets updated externally, I'd want tempValue to be cleared out and reinitialize against the latest value. With a writableComputed in place, this is how it would look:

tempValue = writableComputed(() => this.value());
Enter fullscreen mode Exit fullscreen mode

So whenever value is updated, tempValue takes on that new state as its own. If someone modifies tempValue, that change holds until value itself is altered, at which point the reset occurs.

Proposed solution

Introducing writableComputed—a mechanism that observes the value from the writable component and resets it to the computed output each time that function executes. Illustration:

const a = signal(7);
const b = writableComputed(() => a() + 1);
// b is 8
Enter fullscreen mode Exit fullscreen mode
const a = signal(7);
const b = writableComputed(() => a() + 1);
b.set(9);
// A design question - 8 or 9
Enter fullscreen mode Exit fullscreen mode
const a = signal(7);
const b = writableComputed(() => a() + 1);
a.set(11);
b.set(9);
// A design question - 12 or 9
Enter fullscreen mode Exit fullscreen mode
const a = signal(7);
const b = writableComputed(() => a() + 1);
// b is 8
// Later
b.set(10)
// b is 10
// Later
a.set(20);
// b is 21
Enter fullscreen mode Exit fullscreen mode

Alternatives considered

Leveraging effect is a possible path. The catch arises when inputs are mandatory.

tempValue = signal<string | undefined>(undefined);
effect(() => this.tempValue.set(this.value()), {allowSignalWrites: true});
Enter fullscreen mode Exit fullscreen mode

Consequently, tempValue starts out as undefined, and by the time ngOnInit runs, it remains undefined even though value has already been populated with the real value. At that same point, a computed signal will hold its computed value as well. Invoking writableComputed within ngOnInit will then retrieve the accurate computed result.

Alternatively, this scenario might indicate a fundamental flaw in my current approach, and I am interested in hearing alternative strategies to address it appropriately.