Angular Signals & Your Architecture: 5 Options

This article provides 5 different options and some technical background information for building architectures with Signals.

Signals are one of the most exciting features that have appeared in the Angular space recently. But how do they influence your architecture? This article provides 5 different options and some technical background information you should consider.

📂 Source Code(🔀 Branches:arc-*)

Option 1: Signals in Component

🔀 Branch: arc-simple

A straightforward approach is to use Signals in your components directly. Each property you want to data bind becomes 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);
  }

  [...]

}

Then, you can directly bind these signals 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>

As of writing this, ngModel and other parts of the FormModule don\'t support two-way bindings for Signals. The Angular team will take care of a revision of forms support soon. For this reason, the previous example implements two-way binding manually by explicitly setting up a property binding for ngModel and an event binding for ngModelChange.

In a traditional component, binding a Signal in a template is similar to binding an Observable with the async pipe. Hence, we can also turn on OnPush:

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

Angular checks OnPush components when the async pipe provides a new value. Since Angular 16, the same is the case when a bound Signal is updated. Assigning a new value to the flights Signal makes Angular update the FlightSearchComponent. With Angular 17, we even got first performance improvements for data binding with Signals.

However, this is only one side of the coin: Angular also has to find out which child components need to be updated. It checks the values bound to the child components\' properties. Only the object reference is checked when binding complex data types like objects or arrays.

This is why Observables are usually used together with immutable data structures (Immutables). Instead of mutating such objects, a new object with the changed values is created. For Signals it's the same: We need to use them with Immutables. While originally it was planned to also support mutable data structure, the Angular team decided to enforce Immutability for now to address the discussed challenge.

If we just wanted to delay the first flight, the source code would look like as follows:

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)
  ]));
}

Excursus: Change Detection in Angular -- Today and Tomorrow

When not using OnPush, Angular checks all components for updates after event handlers ran. Components in OnPush mode, however, are only checked if they have been marked as dirty. This happens, for instance, if an Observable bound with async or a bound Signal emits a new value.

When (the object reference of) a bound input changes, the component is marked too. Together with the component, all ancestors in the component tree are marked, as Angular\'s change detection traverses the component tree top/down. Interestingly, marked components are always fully checked for changes, even if only an update affects a part.

Signal Components, envisioned for future Angular versions, will provide a more fine-grained behavior: Only changed parts of components will be checked. These parts will be embedded views defined by control flow statements like @for or @if. Also, ancestors of Signal Components won\'t automatically be checked.

A first optimization in Angular 17 also prevents Angular from checking ancestors.

In this case, having nested signals will provide an advantage over Immutables, as they allow our application to directly notify Angular about the component parts that need to be updated.

Option 2: Move Signals to a Service

🔀 Branch: arc-facade2

Signals are not limited to be used in components. By design, they can be used everywhere. Hence, we can also move 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 services can be used by several components and hence allow for sharing state. But even if only used by one component, they provide some added value: One can relieve the component from taking care of state management, and the service can preserve the state when the component is destroyed and re-created, e.g., when leaving a route and returning to it.

The component can use such a service as follows:

@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();
  }

}

Option 3: Information Hiding With Services

🔀 Branch: arc-facade3

If several components use a service, it might be a good idea to make the exposed Signals read/only. This forces all the components to update the state in a well-defined way, e.g., by calling service methods intended for the update in question.

For creating a read/only Signal out of a writable one, the method asReadonly can be used:

@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 style is also known from RxJS where writable Subjects are converted to read/only Observables. Unfortunately, this code is quite lengthy. The following section provides a solution.

Option 3a: Service with State-Signal

🔀 Branch: arc-facade3a

To streamline option 3 a bit, we could introduce a private writable state Signal and derive the individual 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 already looks like the usage of a lightweight store like the NGRX Component Store. The used helper function patchState does indeed borrow one of the Component Store\'s ideas:

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

Of course, store libraries provide additional features. In particular, the NGRX Signal Store is really tempting.

Option 4: Using a Store and the Redux Pattern

🔀 Branch: arc-ngrx

We could also use a store library as an alternative to manage the state in a service by hand. Choosing one that follows the Redux pattern ensures the state is only modified in a well-defined way. The most popular Redux implementation for Angular is NGRX. Fortunately, it has been supporting Signals since version 16.

The following example shows how its new selectSignal method allows retrieving a part 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 }));
  }

}

The complete usage of NGRX, including the source code defining the state, actions, reducers, and effects, can be found in the 🔀 branch arc-ngrx.

Option 5: Hiding the Store Behind a Facade

🔀 Branch: arc-ngrx-facade

Sometimes, hiding the used store implementation behind a service can be beneficial. Such a so-called facade provides a domain-specific interface and allows to introduce the store gradually or to use 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 know from the beginning that you will go all-in with your store, wrapping it in such a facade might be overhead.

Conclusion

Signals can be defined directly within components but also within a service. In the latter case, public read/only Signals can be derived from a private writable Signal. By providing methods for manipulating the state, one can ensure that the state is only modified in a well-defined way. Instead of hand-writing such a service, existing store libraries adapted for Signals can be used. A famous example is NGRX.

Also, it will be interesting to see how store libraries will evolve to better support Signals and the upcoming Signal Components. A promising development is the envisioned NGRX Signal Store.

What\'s next? More on Architecture!

Please find more information on enterprise-scale Angular architectures in our free eBook (5th edition, 12 chapters):

  • According to which criteria can we subdivide a huge application into sub-domains?
  • How can we make sure, the solution is maintainable for years or even decades?
  • Which options from Micro Frontends are provided by Module Federation?

free

Feel free to download it here now!