Introduction

Angular 17 marked the moment when signals became officially stable.

For those unfamiliar, a signal is essentially a wrapper around a value that has the ability to notify any interested consumers whenever that value undergoes a change. Signals are versatile and can hold anything, from basic primitives right up to intricate data structures.

Currently, the core team's delivery has been limited to the foundational signals API. However, the longer-term vision is considerably more ambitious, aiming to overhaul change detection to be entirely signal-driven.

The immediate next milestone on this roadmap is the introduction of signal-based inputs and outputs. This functionality is anticipated to arrive shortly, potentially within version 17.1 this month.

Given this, what steps can we take now to prepare our existing components for this upcoming shift, making the eventual migration from the traditional Input and Output as smooth as possible?


A Quick Refresher

Before diving into the main topic, it's helpful to recap the fundamentals.

Creating a signal is done by invoking the signal function.

const name = signal<string | null>('DevTo');
Enter fullscreen mode Exit fullscreen mode

This invocation returns a WritableSignal, which equips us with the methods needed to alter the signal's value:

  • set
  • update
const count = signal(0);
count.set(10);
count.update(count => count + 1);
Enter fullscreen mode Exit fullscreen mode

Signals can also be made immutable to external writers by utilizing the asReadonly function.

const count = signal(0);
const readonlyCount = count.asReadonly();
Enter fullscreen mode Exit fullscreen mode

Why Passing a Signal as an Input is a Poor Choice

Dealing with signals in real-world applications can become tricky, particularly when components rely on inputs.

To recap, fine-tuning change detection with signals under OnPush strategy leads to performance gains, especially noticeable in page refresh responsiveness.

When a signal is referenced within the template of an OnPush component, Angular automatically registers it as a reactive dependency. Any subsequent update to that signal causes Angular to flag the component as dirty, guaranteeing its refresh during the next change detection cycle.

Now, consider a scenario with two components in a parent-child relationship:


@Component({
  selector: 'app-father',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [AppChildComponent],
  template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
  name = signal('DevTo');
}


@Component({
  selector: 'app-child',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  template: `{{ name() }}`
})
export class AppFatherComponent {
  @Input({ required: true }) name !: WritableSignal<string>
}
Enter fullscreen mode Exit fullscreen mode

Handing a signal directly to a child component as an input creates several complications:

  • Unidirectional data flow is compromised.
  • The parent is forced to adopt signals for all its inputs.
  • Granular change detection becomes impossible.

Unidirectional Binding and Change Detection Granularity

When a signal is passed as an input, what's actually being passed is its reference, not its current value.

Consequently, if the child component modifies that signal, the parent component is affected as well. This initiates a change detection cycle that runs on both the parent and the child components.

An Angular application is fundamentally a tree of components. By passing signals down as inputs, every modification within a child component will trigger change detection in its ancestors, regardless of whether the OnPush strategy is in use. This negates the primary performance benefit of OnPush.

Furthermore, this approach undermines data centralization. Since any descendant component can modify a passed signal, pinpointing the source of a problem becomes challenging, leading to tangled, difficult-to-debug code.

Forcing Parent Components to Use Signals

One idea to circumvent these issues might be to make the signals read-only.

However, this is both inelegant and an anti-pattern, and it also imposes a heavy constraint: the parent component would have to wrap all its inputs in signals. There'd be no alternative.

This is unreasonable, as a parent should be free to pass primitive values or plain variables to its child components.
On top of that, any code written this way would need a complete overhaul once the official input signals feature is released.


Utilizing Input Setters as a Solution

Angular provides the ability to define inputs as setters, and this feature can be leveraged to pave the way for signal-based inputs.

This technique sidesteps the issues described earlier, although it might introduce some boilerplate code until version 17.1 is available.

Essentially, the strategy is to convert each existing @Input() into a setter and pair it with a corresponding signal.

Let's revisit the previous example.


@Component({
  selector: 'app-father',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [AppChildComponent],
  template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
  name = signal('DevTo');
}


@Component({
  selector: 'app-child',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  template: `{{ name() }}`
})
export class AppFatherComponent {
  @Input({ required: true, alias: 'name' }) set _name(name: string) {
    this.name.set(name);
  }
  name = signal<string>('');
}
Enter fullscreen mode Exit fullscreen mode

This method offers the parent component complete flexibility in what it passes down. Unidirectional binding remains intact, and the eventual migration to signal inputs becomes a much simpler affair.

When version 17.1 arrives, the transition will be straightforward: simply delete the setter and swap the signal function for the input function.


Embracing Signal Inputs

Signal inputs are established using the input function.

This function serves a dual purpose: it generates read-only signals and preserves any metadata that was previously configured on the input decorator.

const name = input<string>(''); // input with default value
const name = input<string>(); // input with no default value
const name = input.required<string>() // input mandatory
const name = input<string>('', { alias: 'lastname' }); // with alias
const isLoading = input<string | boolean; boolean>('', { transform: booleanAttribute });
Enter fullscreen mode Exit fullscreen mode

If we apply this to our previous example, the component code would look like this:


@Component({
  selector: 'app-father',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [AppChildComponent],
  template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
  name = signal('DevTo');
}


@Component({
  selector: 'app-child',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  template: `{{ name() }}`
})
export class AppFatherComponent {
  name = input.required<string>();
}
Enter fullscreen mode Exit fullscreen mode

Looking Ahead

the plan

As the above schema suggests, the future of reactivity and change detection in Angular is firmly rooted in signals.

In the near future, there is a strong possibility that OnPush components utilizing signals will operate without the need for ZoneJs. We may also see a new style of component authoring, focused more explicitly on signals, like the one shown below.

@Component({
  selector: 'app-father',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  signals: true,
  imports: [AppChildComponent],
  template: `<app-child [name]="name" />`
})
export class AppFatherComponent {
  name = signal('DevTo');
}
Enter fullscreen mode Exit fullscreen mode