Signals in Components: The Direct Route

🔀 Branch: arc-simple

The most direct path is to declare Signals right inside your component class. Any property intended for data binding can be turned into a Signal:

@Component({ ... })
export class FlightSearchComponent  {

  private flightService = inject(FlightService);

  from = signal('Hamburg');
  to = signal('Graz');
  flights = signal<Flight[]>([]);

  async search() {
    if (!this.from() || !this.to()) return;

    const flights = await this.flightService.findPromise(this.from(), this.to());
    this.flights.set(flights);
  }

  [...]

}

These Signals can then be wired up directly in the template:

<input [ngModel]="from()" (ngModelChange)="from.set($event)" name="from" />

<input [ngModel]="to()" (ngModelChange)="to.set($event)" name="to" />

<button (click)="search()">Search</button>

<div class="row">
  <div *ngFor="let f of flights()">
    <app-flight-card [item]="f" />
  </div>
</div>

At the time of writing, ngModel and the broader FormModule do not yet handle two-way bindings for Signals. A revised forms implementation is expected from the Angular team shortly. Consequently, the example above manually establishes two-way binding through an explicit property binding on ngModel paired with an event binding on ngModelChange.

Binding a Signal in a conventional component works much like binding an Observable with the async pipe. This similarity also enables the use of OnPush:

// Let's switch on OnPush for
// FlightCardComponent and FlightSearchComponent
@Component({
    [...]
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class [...]  {
    [...]
}

Angular triggers a check for OnPush components whenever the async pipe delivers a fresh value. Starting with Angular 16, the same applies when a bound Signal receives an update. Reassigning the flights Signal will prompt Angular to refresh the FlightSearchComponent. Angular 17 introduced further performance gains for Signal-based data binding.

There's another side to this coin, though: Angular still needs to figure out which child components require an update. It does so by checking values bound to child component properties. When dealing with complex data types—objects or arrays—the comparison is based solely on the object reference.

That is why Observables are commonly paired with immutable data structures. Instead of mutating an object in place, a new object carrying the modified values is produced. Signals follow the same rule: pairing them with Immutables is necessary. While support for mutable data structures was originally on the roadmap, the Angular team has opted to enforce Immutability for now as a way to handle the challenge described above.

As an illustration, here's what delaying the first flight would look like in source code:

delay(): void {
  const flights = this.flights();
  const flight = flights[0];

  const date = addMinutes(flight.date, 15);

  this.flights.update(flights => ([
    { ...flight, date },
    ...flights.slice(1)
  ]));
}

A Closer Look: Change Detection Present and Future

Without OnPush, Angular performs a full component check after event handlers complete. Components flagged with OnPush, however, only undergo checks when they are explicitly marked as dirty. This marking happens, for instance, when an Observable bound through async or a bound Signal pushes out a new value.

A component is also marked when a bound input's object reference changes. Marking a component also marks all of its ancestors up the component tree, since Angular's change detection walks that tree from the top down. Notably, marked components are checked in full, regardless of the scope of the actual change.

Signal Components, planned for future Angular releases, will bring a much finer granularity to this process: Only the specific parts of a component affected by a change will be checked. Those parts will correspond to embedded views created by structural directives such as @for or @if. In addition, the ancestors of a Signal Component would not be automatically checked.

A preliminary optimization shipped with Angular 17 already prevents Angular from checking ancestor components in certain scenarios.

Given this trajectory, nested Signals offer an edge over Immutables: they enable the application to point Angular directly to the precise component sections needing a refresh.

Relocating Signals into a Service

🔀 Branch: arc-facade2

Signals aren't confined to components. They are designed to be used anywhere in the application. That opens the door to moving them into a service:

@Injectable({ providedIn: 'root' })
export class FlightBookingFacade {
  private flightService = inject(FlightService);

  readonly flights = signal<Flight[]>([]);
  readonly from = signal('Hamburg');
  readonly to = signal('Graz');

  async load(): Promise<void> {
    [...]
  }

  delay(): void {
    [...]
  }
}

Such a service can be shared by multiple components, which makes state sharing straightforward. Even if a service is used by just a single component, it still offers value: the component is relieved of state management duties, and the service holds onto the state when the component is recreated—such as when navigating away from a route and returning.

A component could consume such a service in this manner:

@Component({ ... })
export class FlightSearchComponent  {

  private facade = inject(FlightBookingFacade);

  from = this.facade.from;
  to = this.facade.to;
  flights = this.facade.flights;

  async search() {
    this.facade.load();
  }

  delay(): void {
    this.facade.delay();
  }

}

Controlling Access with Services

🔀 Branch: arc-facade3

When a service is shared by several components, exposing Signals as read-only is often a wise move. This forces all components to modify the state through deliberately designed service methods rather than writing directly to the state.

The asReadonly method transforms a writable Signal into a read-only one:

@Injectable({ providedIn: 'root' })
export class FlightBookingFacade {
  private flightService = inject(FlightService);

  private _flights = signal<Flight[]>([]);
  readonly flights = this._flights.asReadonly();

  private _from = signal('Hamburg');
  readonly from = this._from.asReadonly();

  private _to = signal('Graz');
  readonly to = this._to.asReadonly();

  updateCriteria(from: string, to: string): void {
    this._from.set(from);
    this._to.set(to);
  }

  [...]
}

This technique is familiar from RxJS, where writable Subjects are exposed as read-only Observables. The downside, though, is verbosity. The next section offers a more concise alternative.

Refined Service with a State Signal

🔀 Branch: arc-facade3a

As a way to slim down the previous approach, it's possible to maintain a single private writable state Signal and derive the public read-only Signals from it via computed:

import { patchSignal } from '../../../shared/util-common';

[...]

@Injectable({ providedIn: 'root' })
export class FlightBookingFacade {
  private flightService = inject(FlightService);

  private state = signal({
    from: 'Hamburg',
    to: 'Graz',
    flights: [] as Flight[],
    basket: {} as Record<number, boolean>,
  });

  readonly flights = computed(() => this.state().flights);
  readonly from = computed(() => this.state().from);
  readonly to = computed(() => this.state().to);
  readonly basket = computed(() => this.state().basket);

  updateCriteria(from: string, to: string): void {
    patchSignal(this.state, { from, to });
  }

  [...]

}

This pattern starts to resemble a lightweight store like the NGRX Component Store. The patchState helper function indeed borrows a page from that store's playbook:

export function patchSignal<T>(signal: WritableSignal<T>, partialState: Partial<T>) {
  signal.update((state) => ({
    ...state,
    ...partialState,
  }));
}

Store libraries, of course, offer far more than this. The NGRX Signal Store in particular is worth a close look.

Adopting a Store with the Redux Pattern

🔀 Branch: arc-ngrx

A store library can also take over the role of manually managing state in a service. Opting for a library based on the Redux pattern guarantees that state changes happen in a strictly controlled manner. For Angular, NGRX is the most widely used Redux implementation, and it has supported Signals since version 16.

The example below demonstrates how the new selectSignal method fetches a slice of the immutable state tree as a Signal:

@Component({ ... })
export class FlightSearchComponent {
  private store = inject(Store);

  criteria = this.store.selectSignal(ticketingFeature.selectCriteria);
  flights = this.store.selectSignal(ticketingFeature.selectFlights);

  updateCriteria(from: string, to: string): void {
    this.store.dispatch(ticketingActions.updateCriteria({ from, to }));
  }

  search(): void {
    this.store.dispatch(
      ticketingActions.loadFlights({
        from: this.criteria().from,
        to: this.criteria().to,
      })
    );
  }

  delay(): void {
    const flights = this.flights();
    const id = flights[0].id;
    this.store.dispatch(ticketingActions.delayFlight({ id }));
  }

}

Full NGRX usage—including the definitions for state, actions, reducers, and effects—is available in the 🔀 branch arc-ngrx.

Concealing the Store Behind a Facade

🔀 Branch: arc-ngrx-facade

There are times when shielding the store implementation behind a service proves advantageous. This kind of facade exposes a domain-specific API and makes it possible to roll out the store gradually or apply it selectively:

@Injectable({ providedIn: 'root' })
export class FlightBookingFacade {
  private store = inject(Store);

  criteria = this.store.selectSignal(ticketingFeature.selectCriteria);
  flights = this.store.selectSignal(ticketingFeature.selectFlights);

  updateCriteria(from: string, to: string): void {
    this.store.dispatch(ticketingActions.updateCriteria({ from, to }));
  }

  load(): void {
    if (!this.criteria().from || !this.criteria().to) return;
    this.store.dispatch(
      ticketingActions.loadFlights({
        from: this.criteria().from,
        to: this.criteria().to,
      })
    );
  }

  delay(): void {
    const flights = this.flights();
    const id = flights[0].id;
    this.store.dispatch(ticketingActions.delayFlight({ id }));
  }
}

If you intend to fully commit to a store from day one, wrapping it in a facade might introduce unnecessary overhead.

Final Thoughts

Signals can live directly in components or inside services. In the service-based approach, public read-only Signals are commonly derived from a private writable one. By exposing managed methods to alter state, you can rest assured that modifications follow a well-defined path. Rather than building such a service by hand, you can rely on store libraries that have been adapted for Signals—NGRX being a prime example.

It will also be interesting to observe how store libraries continue to evolve in their support of Signals and the forthcoming Signal Components. The projected NGRX Signal Store is a particularly promising development.

Continue Reading: Architecture Deep Dive

Our free eBook (5th edition, 12 chapters) offers much more on enterprise-scale Angular architecture:

  • What criteria help in dividing a large application into manageable sub-domains?
  • How do you ensure a solution remains maintainable over years or even decades?
  • What Micro Frontend options does Module Federation provide?

free

Head over to download it now!