Guiding Principle: Unidirectional Data Flow Using Signals

The strategy for maintaining a unidirectional data flow discussed in the preceding article provides the conceptual foundation for the two recommendations outlined below:

Unidirectional data flow with a store

UI event handlers forward their actions to the store. I use the broad term intention to describe this, since different store implementations handle it in entirely different ways: in the Redux-driven NGRX Store, you dispatch Actions; in the lightweight NGRX Signal Store, however, the component calls a method exposed by the store.

The store then runs synchronous or asynchronous operations. These operations typically lead to state modifications, and the application conveys these changes to component views via signals. Within this flow, computed can project state onto View Models — data shapes that reflect how specific use cases view the underlying state.

This method relies on the fact that signals are primarily designed for synchronously notifying the view about data and data fluctuations. They are not well suited for asynchronous work or event representation: they lack straightforward mechanisms for handling overlapping async requests and the consequent race conditions, nor can they directly express error states. Additionally, signals disregard intermediary states that arise when values change in quick succession — a characteristic known as being glitch-free.

For instance, if a signal moves from 1 to 2 and immediately afterward from 2 to 3, the consumer only learns about the final value 3. This trait also boosts data binding performance, since updating with every intermediate value would trigger needless work.

Recommendation 1: Signals Pair Well with RxJS

Signals are intentionally minimal. As a result, they provide fewer capabilities than RxJS, which has been a cornerstone of the Angular ecosystem for many years. Thanks to Angular's RxJS interop, you can take advantage of the strengths of both approaches. The code below illustrates this:

@Component({
  selector: 'app-desserts',
  standalone: true,
  imports: [DessertCardComponent, FormsModule, JsonPipe],
  templateUrl: './desserts.component.html',
  styleUrl: './desserts.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DessertsComponent {
  #dessertService = inject(DessertService);
  #ratingService = inject(RatingService);
  #toastService = inject(ToastService);

  originalName = signal('');
  englishName = signal('Cake');
  loading = signal(false);

  ratings = signal<DessertIdToRatingMap>({});
  ratedDesserts = computed(() => this.toRated(this.desserts(), this.ratings()));

  originalName$ = toObservable(this.originalName);
  englishName$ = toObservable(this.englishName);

  desserts$ = combineLatest({
    originalName: this.originalName$,
    englishName: this.englishName$,
  }).pipe(
    filter((c) => c.originalName.length >= 3 || c.englishName.length >= 3),
    debounceTime(300),
    tap(() => this.loading.set(true)),
    switchMap((c) =>
      this.#dessertService.find(c).pipe(
        catchError((error) => {
          this.#toastService.show('Error loading desserts!');
          console.error(error);
          return of([]);
        }),
      ),
    ),
    tap(() => this.loading.set(false)),
  );

  desserts = toSignal(this.desserts$, {
    initialValue: [],
  });

  […]
}

This example turns the from and to signals into Observables and builds a typeahead feature on top of them. It leverages the filter, debounceTime, and switchMap operators available in RxJS. The latter also prevents race conditions from overlapping requests by only honoring the most recent one; all other pending requests are aborted.

Finally, the resulting Observable is converted back into a signal so the application can keep using the modern signals API. Naturally, for performance reasons, you should avoid bouncing between the two paradigms too frequently.

Unlike the diagram above, no store participates here. Both the intention and the async operation live inside the reactive pipeline. If you relocate that pipeline into a Service that broadcasts the loaded data — using something like shareReplay — you could regard that service as a rudimentary store. Still, consistent with the earlier figure, the component hands off the asynchronous task to another layer and receives the resulting state through signals.

RxJS Within Stores

RxJS is also frequently embedded in stores. The classic NGRX store, for instance, relies on RxJS for Actions and Effects. As a substitute for Effects, the NGRX Signal Store offers reactive methods that you can define using rxMethod:

export const DessertStore = signalStore(
  { providedIn: 'root' },
  withState({
    filter: {
      originalName: '',
      englishName: 'Cake',
    },
    loading: false,
    ratings: {} as DessertIdToRatingMap,
    desserts: [] as Dessert[],
  }),
  […]
  withMethods(
    (
      store,
      dessertService = inject(DessertService),
      toastService = inject(ToastService),
    ) => ({

      […]
      loadDessertsByFilter: rxMethod<DessertFilter>(
        pipe(
          filter(
            (f) => f.originalName.length >= 3 || f.englishName.length >= 3,
          ),
          debounceTime(300),
          tap(() => patchState(store, { loading: true })),
          switchMap((f) =>
            dessertService.find(f).pipe(
              tapResponse({
                next: (desserts) => {
                  patchState(store, { desserts, loading: false });
                },
                error: (error) => {
                  toastService.show('Error loading desserts!');
                  console.error(error);
                  patchState(store, { loading: false });
                },
              }),
            ),
          ),
        ),
      ),
    }),
  ),
  withHooks({
    onInit(store) {
      const filter = store.filter;
      store.loadDessertsByFilter(filter);
    },
  }),
);

This snippet establishes a reactive loadDessertsByFilter method inside the store. Because it is typed with rxMethod<DessertFilter>, it accepts an Observable<DessertFilter>. The values from this Observable traverse the provided pipe. Since rxMethod subscribes to the Observable automatically, your code must capture the outcome of the pipeline with tap or tabResponse. The latter is an operator from the ngrx/operators package that merges the behavior of tap, catchError, and finalize.

A consumer of a reactive method can pass in an Observable, but also a signal or a concrete value. For example, the onInit hook shown supplies the filter signal. Consequently, every value the signal emits over time flows through the pipe inside loadDessertsByFilter, honoring the glitch-free characteristic noted earlier.

Interestingly, rxMethod is designed to work beyond the Signal Store as well. A component, for instance, could employ it to establish its own reactive method.

Angular Architecture Workshop (online, interactive, advanced)

Master the skills needed for enterprise-scale, maintainable Angular applications through our Angular Architecture workshop!Skillfully Using Signals in Angular – Selected Hints for Professional Use — figure 2

All Details (English Workshop) | All Details (German Workshop)

Recommendation 2: Steering Clear of Race Conditions

Overlapping asynchronous operations often trigger unwanted race conditions. If a user searches for two different desserts in rapid succession, for instance, both outcomes might appear one after another. One result might flash briefly before the other takes its place. Because these operations are asynchronous, the order in which results arrive does not necessarily match the order in which requests were issued.

To avoid this confusing behavior, RxJS provides several flattening operators:

  • switchMap
  • mergeMap
  • concatMap
  • exhaustMap

These operators differ in how they manage overlapping requests. The switchMap operator mentioned earlier always returns only the outcome of the most recent search when multiple requests overlap. Any in-flight requests are canceled the moment a new one arrives. This aligns with what users naturally expect from search filters.

With mergeMap and concatMap, all queries are executed: the former runs them in parallel, while the latter queues them sequentially. The exhaustMap operator, by contrast, ignores any new requests while one is still active. These capabilities are yet another justification for choosing RxJS, alongside the interop layer and rxMethod discussed earlier.

A supplementary or alternative tactic often used is a flag that signals whether the application is currently in communication with the backend:

loadRatings(): void {
  patchState(store, { loading: true });

  ratingService.loadExpertRatings().subscribe({
    next: (ratings) => {
      patchState(store, { ratings, loading: false });
    },
    error: (error) => {
      patchState(store, { loading: false });
      toastService.show('Error loading ratings!');
      console.error(error);
    },
  });
},

Based on this flag's value, the application might show a loading spinner or disable the relevant button. The latter approach, however, undermines a highly reactive interface and is simply not feasible when no explicit button exists.

Wrap-Up

RxJS and signals complement each other elegantly, and Angular's RxJS interop offers the finest aspects of both. I suggest using RxJS for event handling. For asynchronous task processing, RxJS or stores built on RxJS are solid choices. Signals, meanwhile, should manage the synchronous delivery of retrieved data into the view. Together, RxJS, stores, and signals form the foundation for establishing unidirectional data flows.

Furthermore, RxJS's flattening operators provide refined solutions for eliminating race conditions. As an alternative or supplement, flags can indicate whether a backend request is presently underway.