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.

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.

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

The following code would not work when using OnPush as it is not changing the object reference of the respective flight:

// Alternative w/ Mutables:
// Won't work with OnPush
this.flights.mutate((flights) => {
  flights[0].date = date;
});

Instead of using Signals with Immutables, one could also use nested Signals, e. g. a Signal with an Array containing Signals with flights. However, such structures needed to be created by hand; hence I assume that sticking with Immutables is easier for now. The following section explains that switching to nested Signals can speed up the data binding once we get the envisioned Signal Components.

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 structural directives like ngFor or ngIf. Also, ancestors of Signal Components won't automatically be checked.

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 { equal, 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, { equal });
  readonly from = computed(() => this.state().from, { equal });
  readonly to = computed(() => this.state().to, { equal });
  readonly basket = computed(() => this.state().basket, { equal });

  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, and as of writing this, an NGRX Signal Store is in the works. I'm going to update this article once the signal store is available.

The helper function equal tells the Signal, when its value is considered to be changed. As we are going with Immutable state, it's just about comparing object references and primitive values. For both, the === operator is used:

export function equal<T>(a: T, b: T): boolean {
    return a === b;
}

Excursus: The equal Function

Each Signal has an equal function defining when its value is considered to be changed. This function is called after updating the Signal. Only if it returns false, the consumers are notified. At least this is how it can be seen on the logical level. On the implementation level it's a bit more complicated.

Consumers are computed Signals and effects. Angular's change detection also uses the latter to track the bound Signals values.

The default implementation we get when not explicitly setting this function works as follows:

  • For objects (including arrays), false is always returned.
  • Otherwise (e. g. for strings, numbers, booleans), the result of oldValue === newValue is returned.

The first bullet point might be surprising. The reason is that the Signal cannot know how to compare our complex structures, and always performing a deep comparison seems too costly. Just returning false makes it work with mutable data too. Obviously, this also leads to more notifications as actually needed. The comparison described in the second bullet point ensures that consumers are not notified if a signal is updated with the same primitive value.

As we use immutable state in the examples shown here, our custom equal function can always compare the former value with the current value.

Unfortunately, there is a small caveat when using Immutables: When a part of the immutable state is changed, the whole state object must also be changed. You can see this in the discussed patchSignal function: It always returns a new state object, the former state, but also the new partial state is spread into. Consequently, all computed Signals are recomputed. As our computations are just projections (e. g. from state to state().flights), this shouldn't be too costly.

However, it would be costly if Angular marked the components as dirty after each re-computation. The registered equal function prevents this: It returns true if the object reference did not change. As a result, the Signal does not notify Angular, and the component is not marked as dirty.

As of writing this, it seems like all the existing store implementations adapted for Signals recently have this behavior. This isn't that surprising because they internally use immutable data structures. According to a first RFC document, the envisioned NGRX Signal Store will internally have separate Signals for each top-level state property. This reduces the issue at hand.

Option 3b: Adding a Simple Store

πŸ”€ Branch: arc-facade3d

While the previous option was easy to read and maintain, it has two drawbacks: One has to remember to use an equal function for immutables, and there is just one signal for the whole state. Ideally, a Signal just contains elements that usually change together. A simple generic solution would at least create one Signal for each property on root level. Both can be automated with a simple hand-made store:

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

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

  // Option 1: fetch root signals as readonly
  flights = this.state.select(s => s.flights());
  from = this.state.select(s => s.from());
  to = this.state.select(s => s.to())
  basket = this.state.select(s => s.basket());

  // Option 2: use selectors for computing a view model
  selected = this.state.select(s => s.flights().filter(f => s.basket()[f.id]));

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

  [...]
}

Internally, the store maintains a separate signal for each property at root level, and the select`` method is just a wrapper around computed that passes a respective equal function.

The store itself can be written with about 60 lines of code. Please find it here.

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 ebook

Feel free to download it here now!