Thursday, May 18, 2023

Signals in Angular: The Future in Angular development

In this article, we will explore Angular signals, a powerful feature that has recently been introduced by the Angular team. We'll delve into what signals are, their significance, and the problems they aim to address within the existing Angular ecosystem. Signals serve as straightforward reactive primitives that store values for consumers to read. Essentially, they act as wrappers that provide notifications when the stored value changes. To grasp the functionality of signals, we'll proceed by creating a new Angular application and examining their operation firsthand.

Create a new project by running the next command

ng new signal-sample

After creating a new project, it would be great to migrate our project to standalone APIs using angular schematics. As of version 15.2.0, Angular offers a schematic to help project authors convert existing projects to the new standalone APIs. The schematic aims to transform as much code as possible automatically, but it may require some manual fixes by the project author.

Run the migration in the order listed below, verifying that your code builds and runs between each step:

  1. Run ng g @angular/core:standalone and select "Convert all components, directives, and pipes to standalone"
  2. Run ng g @angular/core:standalone and select "Remove unnecessary NgModule classes"
  3. Run ng g @angular/core:standalone and select "Bootstrap the project using standalone APIs"
  4. Run any linting and formatting checks, fix any failures, and commit the result

The last step which is required to upgrade angular to 16 version:

ng update @angular/cli @angular/core --next

Now we can start implementation of our first application and after that change it to signals. Open app.component.ts and add the following code there

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  standalone: true,
  imports: [NgSwitch, NgSwitchDefault, NgSwitchCase],
})
export class AppComponent {
  title = 'signal-sample';

  count = 1;
  price = 12.5;

  get totalSum() {
    return this.count * this.price;
  }

  changeCount(): void {
    this.count++;
  }
}

We also need to update the markup in app.component.html

<p>Count: {{ count }}</p>
<p>Price: {{ price }}</p>
<p>Total Sum: {{ totalSum }}</p>
<button (click)="changeCount()">Change Count</button>

Run this application via npm start to check that everything works as expected.

There is a considerable drawback related to the existing application. Let’s put a console log to our totalSum and check how often this getter will be called.

get totalSum() {
  console.log('totalSum');

  return this.count * this.price;
}

Open the development console to check the result after clicking on the Change Count button.

As you see, after the application has started, we have 4 console log calls and after the button click, we have additional two calls.

Update this code using signal:

export class AppComponent {
  title = 'signal-sample';

  count = signal(1);
  price = 12.5;

  totalSum = computed(() => {
    console.log('totalSum');
    return this.count() * this.price;
  });

  changeCount(): void {
    this.count.update((value: number) => value + 1);
  }
}

These changes required updating the markup as well.

<p>Count: {{ count() }}</p>
<p>Price: {{ price }}</p>
<p>Total Sum: {{ totalSum() }}</p>
<button (click)="changeCount()">Change Count</button>

Let’s run our application and check that everything works as expected without any issues.

As you see, totalSum shows up only once in the console log and after clicking on Change Count, your changes will be applied only one time.

Change Detection Today: Zone.js

Angular assumes that event handlers have the potential to modify any bound data. Consequently, after executing event handlers, the framework performs default checks on all component bindings for changes. However, by leveraging the more powerful OnPush mode, which utilizes Immutables and Observables, Angular can significantly reduce the number of components that require checking.

Regardless of whether we opt for the default behavior or OnPush, Angular needs to be aware of when event handlers are executed. This poses a challenge because it is the browser, not the framework, that triggers these event handlers. To address this issue, Zone.js comes into play. Zone.js extends JavaScript objects like window, document, and prototypes such as HtmlButtonElement, HtmlInputElement, or Promise using monkey patching. By modifying these standard constructs, Zone.js can determine when an event handler has been executed. It then notifies Angular, indicating that it handles the change detection process.

Let’s see how it works under the hood. NgZone will be created when we bootstrap our application:

bootstrapApplication(AppComponent, {
    providers: [importProvidersFrom(BrowserModule)]
})
  .catch(err => console.error(err));

bootstrapApplication calls the internal method internalCreateApplication. The responsibility of this internal method is creating NgZone and using monkey patching technics to patch the browser’s native API and bring some additional behavior, interceptors, and hooks to it.

export function bootstrapApplication(
    rootComponent: Type<unknown>, options?: ApplicationConfig)
    : Promise<ApplicationRef> {
  return internalCreateApplication({rootComponent,
    ...createProvidersConfig(options)});
}


One of the responsibilities of class NgZone is to create a child’s zone exactly for their angular application and it uses special interceptors like onInvokeTask, onInvoke etc. to bring the connections between angular and zone. For example, onInvokeTask callback will be called by Zone to execute macro tasks, microtasks, or events.  So, let’s try to understand how it affects our change detection.

onInvokeTask:
    (delegate: ZoneDelegate, current: Zone, target: Zone, task: Task, applyThis: any,
      applyArgs: any): any => {
      try {
        onEnter(zone);
        return delegate.invokeTask(target, task, applyThis, applyArgs);
      } finally {
        if ((zone.shouldCoalesceEventChangeDetection && task.type === 'eventTask') ||
            zone.shouldCoalesceRunChangeDetection) {
          delayChangeDetectionForEventsDelegate();
        }
        onLeave(zone);
      }
    },

Once the task is executed and before leaving the call stack, Angular checks a few things for micro tasks and event tasks. The most interesting here is the function onLeave.

function onLeave(zone: NgZonePrivate) {
  zone._nesting--;
  checkStable(zone);
}

And check the function checkStable (I’ve removed some Angular team comments because they are redundant)

function checkStable(zone: NgZonePrivate) {
  if (zone._nesting == 0 && !zone.hasPendingMicrotasks && !zone.isStable) {
    try {
      zone._nesting++;
      zone.onMicrotaskEmpty.emit(null);
    } finally {
      zone._nesting--;
      if (!zone.hasPendingMicrotasks) {
        try {
          zone.runOutsideAngular(() => zone.onStable.emit(null));
        } finally {
          zone.isStable = true;
        }
      }
    }
  }
}

Here when we do not have any pending microtasks !zone.hasPendingMicrotasks and just call onMicrotaskEmpty event handler.

/**
 * Notifies when there is no more microtasks enqueued in the current VM Turn.
 * This is a hint for Angular to do change detection, which may enqueue
 * more microtasks.
 * For this reason this event can fire multiple times per VM Turn.
 */
readonly onMicrotaskEmpty: EventEmitter<any> = new EventEmitter(false);

And just the last piece is missing. Who is subscribed to this event?

@Injectable({providedIn: 'root'})
export class NgZoneChangeDetectionScheduler {
  private readonly zone = inject(NgZone);
  private readonly applicationRef = inject(ApplicationRef);

  private _onMicrotaskEmptySubscription?: Subscription;

  initialize(): void {
    if (this._onMicrotaskEmptySubscription) {
      return;
    }

    this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
      next: () => {
        this.zone.run(() => {
          this.applicationRef.tick();
        });
      }
    });
  }

  ngOnDestroy() {
    this._onMicrotaskEmptySubscription?.unsubscribe();
  }
}

Probably the name of this service speaks for itself. When either the microtask or event task is completed, we run change detection by calling this.applicationRef.tick(); in the scope of NgZone. tick() method just calls change detection for all our views.

tick(): void {
  (typeof ngDevMode === 'undefined' || ngDevMode) && this.warnIfDestroyed();
  if (this._runningTick) {
    throw new RuntimeError(
        RuntimeErrorCode.RECURSIVE_APPLICATION_REF_TICK,
        ngDevMode && 'ApplicationRef.tick is called recursively');
  }

  try {
    this._runningTick = true;
    for (let view of this._views) {
      view.detectChanges();
    }
    if (typeof ngDevMode === 'undefined' || ngDevMode) {
      for (let view of this._views) {
        view.checkNoChanges();
      }
    }
  } catch (e) {
    // Attention: Don't rethrow as it could cancel subscriptions to Observables!
    this.internalErrorHandler(e);
  } finally {
    this._runningTick = false;
  }
}

Give it a try to simplify this workflow by using a diagram.

Change Detection Tomorrow: Signals

Here is a simple diagram that demonstrates how the signal works.

count = signal(1);

If the consumer is a template, it can notify Angular about changed bindings. In the terminology of the Angular team, the signal occurs as a so-called producer. As described below, there are also other building blocks that can fill this role.

There are two types of Signals. They can be either writable or read-only.

In our sample we have created two types of signals: count – writable and computed is read-only.

count = signal(1);
totalSum = computed(() => {
  console.log('totalSum');
  return this.count() * this.price;
});

Here is the code of the signal function from angular source code:

export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>):
    WritableSignal<T> {
  const signalNode = new WritableSignalImpl(initialValue, options?.equal
    ?? defaultEquals);

  // Casting here is required for g3, as TS inference behavior is slightly different
  // between our version/options and g3's.
  const signalFn = createSignalFromFunction(signalNode,
                     signalNode.signal.bind(signalNode), {
                     set: signalNode.set.bind(signalNode),
                     update: signalNode.update.bind(signalNode),
                     mutate: signalNode.mutate.bind(signalNode),
                     asReadonly: signalNode.asReadonly.bind(signalNode)
                   }) as unknown as WritableSignal<T>;
  return signalFn;
}

In the code snippet provided, we can see that signal utilizing the WritableSignal<T> class. This class offers four essential methods: set, update, mutate, and asReadonly. To modify the value of a writable signal, you have two options. Firstly, you can use the .set() method to directly assign a new value. Alternatively, you can employ the .update() operation, which allows you to compute a new value based on the previous one. In situations where the signal holds an object, directly mutating the object can be beneficial.

Here's a sample demonstrating the usage of signals, provided by the Angular team:

const todos = signal([{title: 'Learn signals', done: false}]);

todos.mutate(value => {
  // Change the first TODO in the array to 'done: true' without replacing it.
  value[0].done = true;
});

Let’s discover read-only signals like computed and effects.

export function computed<T>(computation: () => T, options?: CreateComputedOptions<T>)
    : Signal<T> {
  const node = new ComputedImpl(computation, options?.equal ?? defaultEquals);

  // Casting here is required for g3, as TS inference behavior is slightly different
  // between our version/options and g3's.
  return createSignalFromFunction(node, node.signal.bind(node)) as unknown
    as Signal<T>;
}

Under the hood, ComputedImpl class has a special method for notifying consumers that the value in signal has potentially changed.

protected override onConsumerDependencyMayHaveChanged(): void {
  if (this.stale) {
    // We've already notified consumers that this value has potentially changed.
    return;
  }

  // Record that the currently cached value may be stale.
  this.stale = true;

  // Notify any consumers about the potential change.
  this.producerMayHaveChanged();
}

If one of the dependencies changed in the scope of computed()  the value will be recomputed.

private recomputeValue(): void {
  if (this.value === COMPUTING) {
    // Our computation somehow led to a cyclic read of itself.
    throw new Error('Detected cycle in computations.');
  }

  const oldValue = this.value;
  this.value = COMPUTING;

  // As we're re-running the computation, update our dependent tracking version
  // number.
  this.trackingVersion++;
  const prevConsumer = setActiveConsumer(this);
  let newValue: T;
  try {
    newValue = this.computation();
  } catch (err) {
    newValue = ERRORED;
    this.error = err;
  } finally {
    setActiveConsumer(prevConsumer);
  }

  this.stale = false;

  if (oldValue !== UNSET && oldValue !== ERRORED && newValue !== ERRORED &&
      this.equal(oldValue, newValue)) {
    // No change to `valueVersion` - old and new values are
    // semantically equivalent.
    this.value = oldValue;
    return;
  }

  this.value = newValue;
  this.valueVersion++;
}

And here is the information on what UNSET, COMPUTING, and ERRORED are.

/**
 * A dedicated symbol used before a computed value has been calculated for the
* first time.
 * Explicitly typed as `any` so we can use it as signal's value.
 */
const UNSET: any = Symbol('UNSET');

/**
 * A dedicated symbol used in place of a computed signal value to indicate that
* a given computation
 * is in progress. Used to detect cycles in computation chains.
 * Explicitly typed as `any` so we can use it as signal's value.
 */
const COMPUTING: any = Symbol('COMPUTING');

/**
 * A dedicated symbol used in place of a computed signal value to indicate that
* a given computation
 * failed. The thrown error is cached until the computation gets dirty again.
 * Explicitly typed as `any` so we can use it as signal's value.
 */
const ERRORED: any = Symbol('ERRORED');

If you are already experienced with state management in the context of Angular, working with computed signals should feel quite familiar. A notable advantage of using the `computed()` function is that signals created with it are lazily evaluated and cached for efficiency. Furthermore, it's important to note that computed signals derive their value from other signals. To define a computed signal, you can utilize the `computed()` function and specify a derivation function:

totalSum = computed(() => {
  return this.count() * this.price;
});

In our sample, we derive from count() signal.

Effects:

The last part of read-only signal is known as an effect(). An effect is an operation that is executed whenever the value of one or more signals undergoes a change. To create an effect, you can employ the effect function:

constructor() {
  effect(() => {
    console.log(`The count is: ${this.count()})`);
  });
}

Effects always run at least once. When an effect runs, it tracks any signal value reads. Whenever any of these signal values change, the effect runs again. Similar to computed signals, effects keep track of their dependencies dynamically, and only track signals which were read in the most recent execution.

Effects always are executed asynchronously, during the change detection process.

export function effect(
    effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
    options?: CreateEffectOptions): EffectRef {
  !options?.injector && assertInInjectionContext(effect);
  const injector = options?.injector ?? inject(Injector);
  const effectManager = injector.get(EffectManager);
  const destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef)
    : null;
  return effectManager.create(effectFn, destroyRef, !!options?.allowSignalWrites);
}

Probably, you are interested in how it works under the hood. The functionality is very simple for understanding there.

export class EffectManager {
  private all = new Set<Watch>();
  private queue = new Map<Watch, Zone|null>();

  create(
      effectFn: (onCleanup: (cleanupFn: EffectCleanupFn) => void) => void,
      destroyRef: DestroyRef|null, allowSignalWrites: boolean): EffectRef {
    const zone = (typeof Zone === 'undefined') ? null : Zone.current;
    const watch = new Watch(effectFn, (watch) => {
      if (!this.all.has(watch)) {
        return;
      }

      this.queue.set(watch, zone);
    }, allowSignalWrites);

    this.all.add(watch);

    // Effects start dirty.
    watch.notify();

    let unregisterOnDestroy: (() => void)|undefined;

    const destroy = () => {
      watch.cleanup();
      unregisterOnDestroy?.();
      this.all.delete(watch);
      this.queue.delete(watch);
    };

    unregisterOnDestroy = destroyRef?.onDestroy(destroy);

    return {
      destroy,
    };
  }

  flush(): void {
    if (this.queue.size === 0) {
      return;
    }

    for (const [watch, zone] of this.queue) {
      this.queue.delete(watch);
      if (zone) {
        zone.run(() => watch.run());
      } else {
        watch.run();
      }
    }
  }

  get isQueueEmpty(): boolean {
    return this.queue.size === 0;
  }

  /** @nocollapse */
  static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
    token: EffectManager,
    providedIn: 'root',
    factory: () => new EffectManager(),
  });
}

First of all, Angular creates a queue for watch and zone and adds the effect to this queue. After that method flush() run all watches from the queue by calling watch.run().

/**
 * Execute the reactive expression in the context of this `Watch` consumer.
 *
 * Should be called by the user scheduling algorithm when the provided
 * `schedule` hook is called by `Watch`.
 */
run(): void {
  this.dirty = false;
  if (this.trackingVersion !== 0 && !this.consumerPollProducersForChange()) {
    return;
  }

  const prevConsumer = setActiveConsumer(this);
  this.trackingVersion++;
  try {
    this.cleanupFn();
    this.cleanupFn = NOOP_CLEANUP_FN;
    this.watch(this.registerOnCleanup);
  } finally {
    setActiveConsumer(prevConsumer);
  }
}

Use-cases for effects recommended by Angular team:

Effects are rarely needed in most application code, but may be useful in specific circumstances. Here are some examples of situations where an effect might be a good solution:

  • Logging data being displayed and when it changes, either for analytics or as a debugging tool
  • Keeping data in sync with window.localStorage
  • Adding custom DOM behavior that can't be expressed with template syntax
  • Performing custom rendering to a <canvas>, charting library, or other third-party UI library

You should avoid using effects for propagation of state changes. This can result in ExpressionChangedAfterItHasBeenChecked errors, infinite circular updates, or unnecessary change detection cycles.

Use RxJs as a workaround before signals released

Signals will be available only when Angular 16 is released, however, you can use now a similar functionality by using RxJs library based on observables. For example, computed and effect can be replaced by BehaviorSubject + combineLatest function. Because in our case we have only one dependency (count property), let’s check how our code will look with BehaviorSubject.

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  standalone: true,
  imports: [NgSwitch, NgSwitchDefault, NgSwitchCase, AsyncPipe],
})
export class AppComponent {
  title = 'signal-sample';

  price = 12.5;

  count$ = new BehaviorSubject<number>(1);
  totalSum$ = this.count$.pipe(map((count) => count * this.price));

  changeCount(): void {
    this.count$.next(this.count$.value + 1);
  }
}

And our template should look familiar to you as well.

<p>Count: {{ count$ | async }}</p>
<p>Price: {{ price }}</p>
<p>Total Sum: {{ totalSum$ | async }}</p>
<button (click)="changeCount()">Change Count</button>

While signals may appear similar to Angular's long-standing mechanism, RxJS observables, they are intentionally designed to be simpler and often adequate for many scenarios.

For more complex cases, signals can be seamlessly integrated with observables. The @angular/core/rxjs-interop namespace provides two functions: toObservable, which converts a signal into an observable, and toSignal, which performs the reverse conversion. These functions enable the utilization of signal simplicity alongside the robust capabilities of RxJS.

Signals benefits:

Signals represent a major advancement in Angular's reactive programming capabilities and change detection features. Signals are on their way! They will enhance the reactivity and change detection of our code, making it more responsive. Additionally, they will simplify the code creation and readability process. And let's not forget, they bring a lot of excitement and fun! They are new game changers in Angular.

NB! Do not think of signals as a new idea that was implemented by the Angular team. Preact, Solid, and Vue all employ some version of this concept to great success. Angular team has taken the expertise of SolidJS framework. “We've taken a lot of inspiration from these and other reactive frameworks, and we are especially grateful to Ryan Carniato of SolidJS for his willingness to share his expertise and experience in many conversations over the last year. That said, requirements across frameworks differ widely, and we've designed our version of signals to both meet Angular's specific needs and as well as take full advantage of Angular's unique strengths.”

No comments:

Post a Comment