angular · GitHub

Sub-RFC 3: Signal-based Components

Changelog

April 10, 2023

  • Added section on effects and change detection, explaining the timing of effects relative to the change detection process.
  • Added section on afterRender & other functions describing them as application hooks, not component lifecycle hooks.
  • Reworked section on component lifecycle hooks to align with new direction: signal components have ngOnInit and ngOnDestroy only, and other hooks replaced by signal functionality.

Introduction

This discussion covers the API of signal-based components.

Note: this document applies to both components and directives. However, repeating "components and directives" is rather cumbersome, so for brevity we just say "components" everywhere.

Signal-based components

Components are the primary building block of an Angular application. As such, they make a natural boundary for participation in the signal-based reactivity model.

You can mark a component as a signal-based component by setting signals: true in the component metadata:

@Component({
  signals: true,
  ...
})

This setting controls a number of framework behaviors outlined in the rest of this document.

We'll refer to components with signals: false as zone-based components.

You can use both signal-based components and zone-based components in a single application.

Signal-based components cannot extend zone-based components or the other way around.

_Note: we know setting both signals: true and standalone: true is kind of boilerplate-y. We're still thinking of ways to make this more concise.

Using a signal in a component

Let's take a look at a counter component that uses signals:

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
  <!-- count is invoked as a getter! -->
  <p>Count {{ count() }}</p>
  <button (click)="increment()">Increment count</button>`,
})
export class SimpleCounter {
  count = signal(0); // WritableSignal<number>
  increment() {
    this.count.update(c => c + 1);
  }
}

You always call the signal getter to retrieve the signal's value.

Isn't calling a function in a template slow?

Angular developers have learned over the years to avoid calling functions inside templates because the function re-runs on every change detection. This idea no longer applies in a signal-based component because the expressions will only re-evaluate as a result of a signal dependency changing.

Could Angular automatically "unwrap" signal values?

You might be wondering if Angular instead could support binding to a signal directly, implicitly reading the signal's value. In the example above, that would change the binding to count to <p>{{count}}</p>. This would be similar to the way Vue or Preact automatically unwrap the value in templates.

Angular cannot automatically unwrap signal values like this for a couple of reasons:

  • A component may want to bind or accept a signal as a value rather than always reading a signal's contained value.
  • Angular compiles templates to JavaScript code at compile-time. However, the tracking mechanism inside signals operates entirely at run-time. As such, developers can deeply nest a signal read inside a function call. For example:
<p>{{ someObject.someMethod().someProperty }}</p>
class SomeObject {
  items = signal(['a', 'b', 'c']);
  someMethod(): string {
    return this.items()[0].toUpperCase();
  }
}

Here, Angular cannot statically know (at compile-time) that the template expression accesses a signal getter.

Further, explicitly calling the signal getter in the template makes reading values consistent inside and outside of templates.

We know that this is a departure from Angular today that may feel strange for some people, but we've found in our early user studies that developers internalize the concept and it becomes familiar relatively quickly.

Reading non-signal values in templates

Let's look at an example that mixes use of signal and non-signal values in a template:

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
  <!-- count is invoked as a getter! -->
  <p>Count {{ count() }}</p>
  <p>{{ name }}</p> <!-- Not reactive! -->
  <button (click)="increment()">Increment count</button>`,
})
export class SimpleCounter {
  count = signal(0); // WritableSignal<number>
  name = 'Morgan';
  increment() {
    this.count.update(c => c + 1);
  }
}

This example includes both binding to a signal value (count()) and a plain value (name). Let's say that the name and the value of count were both to change. You might expect that only the changes to count() would be reflected in the rendered DOM, but in this scenario Angular would update both values in the rendered DOM. This happens because Angular still re-checks every binding within the component when one or more signal dependencies change. Only a change to the count would trigger that change detection, however.

This behavior can be surprising- in a signal-based component, you might expect that only bindings that read from signals should ever update.

In the signal-based component world, this behavior is a symptom of not embracing the central principle - dynamic state (particularly state referenced from a template) must be tracked within a signal.

We have some ideas on how to help guard against this behavior:

  • Raise an error at run-time if a template expression changes without depending on a signal read.
  • Raise a compile-time warning or error via the Angular Language Service if you bind to a non-signal value that is not readonly

See "Granularity of change detection" for background on this behavior.

Discussion point 3A: We're actively looking for feedback on the developer experience of this behavior.

Computed properties

You can define computed properties similar to how you define writable signals. Let's look at an example of component that shows a Fahrenheit temperature from a Celsius source:

@Component({
  signals: true,
  selector: 'temperature-calc',
  template: `
    <p>C: {{ celsius() }}</p>
    <p>F: {{ fahrenheit() }}</p>
  `,
})
export class SimpleCounter {
  celsius = signal(25);
  // The computed only re-evaluates if celsius() changes.
  fahrenheit = computed(() => this.celsius() * 1.8 + 32);
}

Computed properties can simplify your code and reduce unnecessary recomputation by replacing:

  • Manually updating properties in lifecycle methods
  • Function calls in template expressions
  • Pure pipes in templates which transform input values when they change

Computed properties are a longstanding feature request for Angular.

Signal-based inputs

In signal-based components, all component inputs are signals. Let's look at an example of declaring some signal-based inputs:

@Component({
  signals: true,
  selector: 'user-profile',
  template: `
    <p>Name: {{ firstName() }} {{ lastName() }}</p>
    <p>Account suspended: {{ suspended() }}</p>
  `,
})
export class UserProfile {
  // Create an optional input without an initial value.
  firstName = input<string>(); // Signal<string|undefined>
  // Create an input with a default value
  lastName = input('Smith'); // Signal<string>
  // Create an input with options.
  suspended = input<boolean>(false, {
    alias: 'disabled',
  }); // Signal<boolean>
}

Here, the input function replaces the @Input decorator as the means of declaring a component input.

input accepts two parameters:

  • An initial value, similar to signal, and
  • An options object

input returns a read-only Signal. Reading the value from an input always returns the up-to-date bound value.

To a consumer of the component, signal-based inputs are indistinguishable from non-signal inputs, using the same syntax for binding. Whether or not an input is signal-based is an implementation detail of the receiving component.

Model inputs

Signal-based components additionally have access to a new type of input, model inputs.

@Component({
  signals: true,
  selector: 'some-checkbox',
  template: `
    <p>Checked: {{ checked() }}</p>
    <button (click)="toggle()">Toggle</button>
  `,
})
export class SomeCheckbox {
  // Create a model, which is a *writable* signal that supports two-way binding.
  checked = model(false);
  toggle() {
    // Normal inputs are read-only, but a model is writable
    // and propagates the value back to the parent.
    checked.update(c => !c);
  }
}
@Component({
  signals: true,
  selector: 'some-page',
  template: `
    <!-- Note that the getter is *not* called here, the raw signal is passed -->
    <some-checkbox [(checked)]="isAdmin" />
  `,
})
export class SomePage {
  isAdmin = signal(false);
}

The model function defines a special kind of input that establishes a contract between parent component and child component. A model input gives you a WritableSignal, which propagates its value back to the source. This lets you create two-way bindings without any additional requirements.

When using two-way binding, the binding accepts a WritableSignal reference and not the unwrapped signal value. By passing the signal reference instead of the value, you're explicitly opting into the contract that the child component can write values into your signal. We see this pattern as additionally illustrating, by contrast, the value of explicitly invoking the signal getting for one-way bindings.

One-way binding behavior

You can create one-way bindings on model inputs just like the standard input. Modifying the

example above:

@Component({
  signals: true,
  selector: 'some-page',
  template: `
    <some-checkbox [checked]="isAdmin()" />
  `,
})
export class SomePage {
  isAdmin = signal(false);
}

In this situation, the SomeCheckBox component declares a model called checked and can still change its values, but the values it writes will not propagate back to the parent (SomePage). If the bound value from the parent component (SomePage) changes, it will overwrite the model's value. The model value (checked) will always reflect the most recently set value. This matches the current behavior of Angular inputs.

Two-way-binding syntax

This RFC uses Angular's existing banana-in-a-box syntax, [(propertyName)] syntax for two-way bindings. This syntax originally came about as a shorthand for both a property binding and an event binding. For example, the following two are equivalent:

<my-control [(value)]="something" />
<!-- is equivalent to -->
<my-control [value]="something" (valueChange)="something = $event" />

However, in signal-based components, this original thinking doesn't exactly apply; the two-way binding does not literally map to an event handler. Instead, the value propagates back up when the component sets a value into the model property. Because of this, it might make sense to use an alternate syntax for two-way binding in signal-based components.

Discussion point 3B: Should signal-based components introduce a new syntax for two-way bindings?

Inputs are read-only

In signal-based components, inputs are read-only inside the component that receives them. This is a departure from Angular's current approach, where inputs are mutable everywhere. We believe that making inputs read-only will lead to cleaner, easier-to-follow code. The input signal's value always reflects the most recent value bound into the component from outside.

When adapting existing Angular components to a signals world, model provides a way to preserve the original behavior.

Discussion point 3C: We recognize that making inputs read-only is a significant change that may make adopting signals more difficult for existing projects. While we believe that this decision produces the best outcome, it may be too different. We want your feedback on how disruptive this change would be.

Input configuration

Inputs take an options object, which allows specifying the initial value of the input when not bound, the alias, whether the input is required, etc.

user = input<User>({
  initialValue: {username: 'unknown'},
  alias: 'currentUser',
});

Many inputs have primitive values (boolean, number, string, etc). There is an alternate syntax available which uses the first argument for the initial value:

isLoggedIn = input(true);
isAllPowerful = input(false, {alias: 'isAdmin'});

These are equivalent to the longer forms which use the initialValue option:

isLoggedIn = input({initialValue: true});
isAllPowerful = input({initialValue: false, alias: 'isAdmin'});

Discussion point 3D: We're interested in hearing opinions on the developer experience of this API.

Why no decorator?

See "Decorators in signal-based components" below.

input and model have special meaning to Angular

See "Special functions in signal-based APIs" below.

Signal-based queries

Angular queries include the APIs ViewChild, ContentChild, ViewChildren, and ContentChildren.

In signal-based components, all queries produce signals. Let's look at an example of declaring some signal-based queries:

@Component({
  signals: true,
  selector: 'form-field',
  template: `
    <field-icon *ngFor="let icon of icons()"> {{ icon }} </field-icon>
    <div class="focus-outline">
      <input #field>
    </div>
  `
})
export class FormField {
  icons = viewChildren(FieldIcon); // Signal<FieldIcon[]>
  input = viewChild<ElementRef>('field'); // Signal<ElementRef>
  someEventHandler() {
    this.input().nativeElement.focus();
  }
}

Queries work the same way as before in signal-based components with one exception: they always return a Signal. This applies to both singular (child) and plural (children) queries. This has the side effect of eliminating the QueryList API in signal-based components.

Why no decorator?

See "Decorators in signal-based components" below.

Query functions have special meaning to Angular

See "Special functions in signal-based APIs" below.

Output in signal-based components

Adopting signals as a reactivity primitive in Angular does not inherently affect component outputs. However, we believe that maintaining consistency between the API for inputs and outputs is important. Let's look at the proposed output API for signal-based components:

@Component({
  signals: true,
  selector: 'simple-counter',
  template: `
    <button (click)="save()">Save count</button>
    <button (click)="reset()">Reset count</button>
  `,
})
export class SimpleCounter {
  saved = output<number>(); // EventEmitter<number>
  cleared = output<number>({alias: 'reset'});
  save() {
    this.saved.emit(123);
  }
  reset() {
    this.cleared.emit(456);
  }
}

Aside from the change in declaration API, output behavior remains unchanged.

Why no decorator?

See "Decorators in signal-based components" below.

output has special meaning to Angular

See "Special functions in signal-based APIs" below.

effects and Change Detection

effects allow for the execution of side effects when dependent signals change. Although effects are usable throughout the application, their close coupling with the change detection operation has several implications on component & directive authoring.

Effects wait for inputs to be set

When an effect is created during the construction of a component, that effect will not execute until that component's inputs are available. This guarantee means that effects can be written which depend on component inputs without having to guard against early execution.

Effects run during zone change detection

As Angular is traversing the component tree during change detection, any effects which are queued will be eagerly flushed.

Effects can become dirty during change detection if they observe a signal input which is bound in a zone component. That input isn't change detected until the parent zone component is processed, making the effect dirty.

It's important that the effect is flushed eagerly, as it might affect future rendering of that component. For example it might create or destroy views (such as a reactive form of NgIf might do).

Risk of ExpressionChangedAfterItHasBeenChecked errors

Because effects flush during change detection, they behave similarly to other lifecycle hooks (e.g. ngOnInit) with respect to application data flow. That is, using an effect which reads component inputs and further modifies application state may result in ExpressionChangedAfterItHasBeenChecked errors in other parts of the application, if the modified state is read in components which have already been change detected.

Therefore, caution should be used when authoring effects which set other signals and change application state. This includes converting signals to Observables via toObservable, as this exposes the effect timing via the Observable.

This issue only exists when interleaving signal and zone components. In a fully signal-based application, effects cannot become dirty during change detection, eliminating this risk.

Application rendering lifecycle

To supplement signal components, we're introducing three new application-level lifecycle hooks focused on running code after Angular performs any rendering operations. These are:

afterNextRender

function afterNextRender(fn: () => void): void;

afterNextRender schedules a function to execute after the next rendering operation (that is, change detection cycle) is complete. This is useful whenever you want to read or write from the DOM manually.

Delaying DOM reads until after the framework has finished writing to the DOM during rendering is essential to avoiding unnecessary reflows.

@Component({
  template: `
    <p #p>{{ longText() }}</p>
  `,
})
export class AfterRenderCmp {
  constructor() {
    afterNextRender(() => {
      console.log('text height: ' + p().nativeElement.scrollHeight);
    })
  }
  p = viewQuery('p');
}

You can use afterNextRender in any injection context, including components, directives, or services.

afterRender

function afterRender(fn: () => void): {destroy(): void};

afterRender schedules a function to be executed after any time the framework performs DOM updates during rendering.

You can use afterRender in any injection context, including components, directives, or services.

afterRenderEffect

function afterRenderEffect(fn: () => void): {destroy(): void};

afterRenderEffect is a special kind of effect which, when triggered, executes with afterRender timing.

Component lifecycle and effects

Zone-based components support eight different lifecycle methods that let you hook into different stages of Angular's change detection. Because many of these methods are tightly coupled to Angular current change detection model, they don't make sense for signal-based components.

Signal-based components will retain the following lifecycle methods:

  • ngOnInit
  • ngOnDestroy

The remaining lifecycle methods will not be available in signal components. For all of these methods, signals provide new patterns to achieve the same use cases:

  • ngOnChanges - is used to observe changes to inputs. As inputs are signal-based, computed can be used to derive new values, or effect to react side-effectfully.
  • ngDoCheck - typically this hook was used to implement custom change detection. effect is a likely (and more performant) replacement.
  • ngAfterViewInit is often used to perform some action after initial rendering. afterNextRender can be used instead (and is more correct).
  • ngAfterContentInit, ngAfterViewChecked and ngAfterContentChecked are often used to observe query results. Since queries are also signal-based and therefore reactive by default, signals can be used directly.

Decorators in signal-based components

You might have noticed a pattern in the APIs presented in this RFC: signal-based components don't use any member decorators.

Decorators have long been a hallmark of Angular's authoring experience. For inputs and queries inside signal-based components, we believe that the proposed function-based APIs produce the best overall developer experience.

Let's use input to illustrate our thought process.

Each input needs to create a Signal instance to contain its value at construction time. Imagine a theoretical API where this happens with decorators:

class UserProfile {
  @Input() isAdmin = createInputSignal(false);
}

We find this API unappealing because it introduces significantly more boilerplate for declaring inputs. You might ask "Can Angular automatically create the signal behind the scenes?" Let's imagine another such API:

class UserProfile {
  @Input({defaultValue: false}) isAdmin!: Signal<boolean>;
}

We find this API unappealing because:

  • It relies on the Angular compiler to initialize the input, which is confusingly magical for any developer that isn't already familiar with Angular.
  • It requires the non-null assertion operator, which is easy to forget and potentially confusing for new developers.
  • It introduces more boilerplate by requiring both the decorator and the explicit Signal type.

This leads us back to the proposed API:

class UserProfile {
  isAdmin = input(false);
}

We find this API appealing because:

  • It's concise.
  • The type is inferred naturally without any compiler magic.
  • It satisfies TypeScript's strictPropertyInitialization setting.

Above we discussed @Input, @Output, and all the query decorators. This leaves @HostBinding and @HostListener, which we further propose dropping in signal-based components. Because input, output, and the query functions provide a much cleaner, more concise API, dropping @HostBinding and @HostListener results in a consistent authoring experience.

Special functions in signal-based APIs

Today, Angular processes decorators like @Input at compile-time. Your run-time JavaScript bundle does not actually include any decorators. The new signal-based function APIs behave the same way- Angular will process input, output, viewChild, etc. at compile-time to statically understand the declarations.

This means that you can't use these APIs as typical functions throughout your code. For example, you cannot create a custom booleanFlag that calls input:

function booleanFlag(config) {
  // The Angular compiler won't accept this.
  return input(false, config);
}

Instead, you can exclusively use these APIs as part of a component property initializer. Angular will report an error if these functions are used in any other context.

Why not ChangeDetectionStrategy.Signals?

Longtime Angular developers might ask here, "Why not ChangeDetectionStrategy.Signals?" We considered this option, but decided on a separate API for two reasons:

  • Opting into signals affects a much wider range of behaviors than change detection alone.
  • Directives don't currently support the changeDetection setting. Adding this setting to directives would be confusing, since directives participate in change detection based on the view in which they're contained. However, directives are affected by other aspects of participation in the signal reactivity system.

Signal-based components completely drop the changeDetection setting.

Will there be lint warnings or compiler checks for common anti-patterns with signals?

Yes! We're still exploring what kinds of checks we'll be able to implement, but some early ideas include:

  • Ensuring that inputs/outputs/other signal properties are readonly (the value inside the signals should change, not the identity of the signal itself)
  • Depending on non-readonly, non-signal values in component templates
  • Performing mutable operations or other side effects inside computed computation functions

Read the original on github.com ↗