The new Signals in Angular are a simple reactive building block. However, as is so often the case, the devil is in the detail. In this article, I will give three tips to help you use Signals in a more straightforward way. The examples used for this can be found here.
Guiding theory: Unidirectional data flow with signals
The approach for establishing a unidirectional data flow (Fig. 1) serves as the guiding theory for my three tips.
Fig. 1: Unidirectional data flow with a store
Handlers for UI events delegate to the store. I use the abstract term “intention”, since this process is different for different stores. With the Redux-based NgRx store, actions are dispatched; whereas with the lightweight NgRx Signal store, the component calls a method offered by the store.
The store executes synchronous or asynchronous tasks. These usually lead to a status change, which the application transports to the views of the individual components with signals. As part of this data flow, the state can be projected onto view models using computed, i.e. onto data structures that represent the view of individual use cases on the state.
This approach is based on the fact that signals are primarily suitable for informing the view synchronously about data and data changes. They are less suitable for asynchronous tasks and for representing events. For one, they don’t offer a simple way of dealing with overlapping asynchronous requests and the resulting race conditions. Furthermore, they cannot directly represent error states. Second, signals ignore the resulting intermediate states in the case of directly consecutive value changes. This desired property is called “glitch free”.
For example, if a signal changes from 1 to 2 and immediately afterwards from 2 to 3, the consumer only receives a notification about the 3. This is also conducive to data binding performance, especially as updating with intermediate results would result in an unnecessary performance overhead.
iJS Newsletter
Keep up with JavaScript’s latest news!
Tip 1: Signals harmonize with RxJS
Signals are deliberately kept simple. That’s why it offers fewer options than RxJS, which has been established in the Angular world for years. Thanks to the RxJS interop that Angular provides, the best of both worlds can be combined. Listing 1 demonstrates this. It converts the signals from and to into observables and implements a typeahead based on them. To do this, it uses the operators filter, debounceTime and switchMap provided by RxJS. The latter prevents race conditions for overlapping requests by only using the most recent request. SwitchMap aborts requests that have already been started, unless they have already been completed.
Listing 1
@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: [],
});
[…]
}
At the end, the resulting observable is converted into a signal so that the application can continue with the new Signals API. For performance reasons, the application should not switch between the two worlds too frequently.
In contrast to Figure 1, no store is used. Both the intention and the asynchronous action take place in the reactive data flow. If the data flow were outsourced to a service and the loaded data were shared with the shareReplay operator, this service could be regarded as a simple store. However, in line with Figure 1, the component already hands over the execution of asynchronous tasks in the expansion stage shown and receives signals at the end.
RxJS in Stores
RxJS is also frequently used in stores, like in NgRx in combination with Effects. Instead, the NgRx Signal Store offers its own reactive methods that can be defined with rxMethod (Listing 2).
Listing 2
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 example sets up a reactive method loadDessertsByFilter in the store. As it is defined with rxMethod, it receives an observable. The values of this observable pass through the defined pipe. As rxMethod automatically logs on to this observable, the application code must receive the result of the data flow using tap or tabResponse. The latter is an operator from the @ngrx/operators package that combines the functionality of tap, catchError and finalize.
The consumer of a reactive method can pass a corresponding observable as well as a signal or a specific value. The onInit hook shown passes the filter signal. This means all values that the signal gradually picks up pass through the pipe in loadDessertsByFilter. This is where the glitch-free property comes into play.
It is interesting to note that rxMethod can also be used outside the signal store by design. For example, a component could use it to set up a reactive method.
Tip 2: Avoiding race conditions
Overlapping, asynchronous operations usually lead to undesirable race conditions. If users search for two different desserts in quick succession, both results are displayed one after the other. One of the two only flashes briefly before the other replaces it. Due to the asynchronous nature, the order of the search queries doesn’t have to match each of the results obtained.
To prevent this confusing behavior, RxJS offers a few flattening operators:
- switchMap
- mergeMap
- concatMap
- exhaustMap
These operators differ in how they deal with overlapping requests. The switchMap only deals with the last search request. It cancels any queries that are already running when a new query arrives. This behavior corresponds to what users intuitively expect when working with search filters.
The mergeMap and concatMap operators execute all requests: the former in parallel and the latter sequentially. The exhaustMap operator ignores further requests as long as one is running. These options are another reason for using RxJS and for the RxJS interop and rxMethod.
Another strategy often used in addition or as an alternative is a flag that indicates if the application is currently communicating with the backend.
Listing 3
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);
},
});
},
Depending on the flag’s value, the application can display a loading indicator or deactivate the respective button. The latter is counterproductive or even impossible with a highly reactive UI if the application can manage without an explicit button.
Tip 3: Signals as triggers
As mentioned earlier, Signals are especially suitable for transporting data to the view, like what’s seen on the right in Figure 1. Real events, UI events, or events displayed with RxJS are the better solution for transmitting an intention. There are several reasons why: First, Signals’ glitch-free property can reduce consecutive changes to the last change.
Consumers must subscribe to the Signal in order to be able to react to value changes. This requires an effect that triggers the desired action and writes the result to a signal. Effects that write to Signals are not welcome. By default, they are even penalized by Angular with an exception. The Angular team wants to avoid confusing reactive chains – changes that lead to changes, which in turn, lead to further changes.
On the other hand, Angular is converting more and more APIs to signals. One example is Signals that can be bound to form fields or Signals that represent passed values (inputs). In most cases, you could argue that instead of listening for the Signal, you can also use the event that led to the Signal change. But in some cases, this is a detour that bypasses the new signal-based APIs.
Listing 4 shows an example of a component that receives the ID of a data set to be displayed as an input signal. The router takes this ID from a routing parameter. This is possible with the relatively new feature withComponentInputBinding.
Listing 4
@Component({ […] })
export class DessertDetailComponent implements OnChanges {
store = inject(DessertDetailStore);
dessert = this.store.dessert;
loading = this.store.loading;
id = input.required({
transform: numberAttribute
});
[…]
}
This component’s template lets you scroll between the data records. This logic is deliberately implemented very simply for this example:
<button [routerLink]="['..', id() + 1]" >
Next
</button>
When scrolling, the input signal id receives a new value. Now, the question arises as to how to trigger the loading of the respective data set in the event of this kind of change. The classic procedure is using the live cycle hook ngOnChanges:
ngOnChanges(): void {
const id = this.id();
this.store.load(id);
}
For the time being, there’s nothing wrong with this. However, the planned signal-based components will no longer offer this lifecycle hook. The RFC provides using effects as a replacement.
To escape this dilemma, an rxMethod (e.g. offered by a signal store) can be used:
constructor() {
this.store.rxLoad(this.id);
}
It should be noted that the constructor transfers the entire signal and not just its current value. The rxMethod subscribes to this Signal and forwards its values to an observable that is used within the rxMethod.
If you don’t want to use the signal store, you can instead use the RxJS interop discussed above and convert the signal into an observable with toObservable.
If you don’t have a reactive method to hand, you might be tempted to define an effect for this task:
constructor() {
effect(() => {
this.store.load(this.id());
});
}
Unfortunately, this leads to the exception in Figure 2.
Fig. 2: Error message when using effect
This problem arises because the entire load method that writes a Signal in the store is executed in the reactive context of the effect. This means that Angular recognizes an effect that writes to a Signal. This has to be prevented by default for the reasons above. It also means that Angular triggers the effect again even if a Signal read in load changes.
Both problems can be prevented by using the untracked function (Listing 5).
Listing 5
constructor() {
// try to avoid this
effect(() => {
const id = this.id();
untracked(() => {
this.store.load(id);
});
});
}
With this common pattern, untracked ensures that the reactive context does not spill over to the load method. It can write to Signals and the effect doesn’t register for Signals that read load. Angular only triggers the effect again when the Signal id changes, especially since it reads it outside of untracked.
Unfortunately, this code is not especially easy to read. It’s a good idea to hide it behind a helper function:
constructor() {
explicitEffect(this.id, (id) => {
this.store.load(id);
});
}
The created auxiliary function explicitEffect receives a signal and subscribes to it with an effect. The effect triggers the transferred lambda expression using untracked (Listing 6).
Listing 6
import { Signal, effect, untracked } from "@angular/core";
export function explicitEffect<T>(source: Signal<T>, action: (value: T) => void) {
effect(() => {
const s = source();
untracked(() => {
action(s)
});
});
}
Interestingly, the explicit definition of Signals to be obeyed corresponds to the standard behavior of effects in other frameworks, like Solid. The combination of effect and untracked shown is also used in many libraries. Examples include the classic NgRx store, the RxJS interop mentioned above, the rxMethod, or the open source library ngxtension, which offers many extra functions for Signals.
iJS Newsletter
Keep up with JavaScript’s latest news!
To summarize
RxJS and Signals harmonize wonderfully together and the RxJS interop from Angular gives us the best of both worlds. Using RxJS is recommended for representing events. For processing asynchronous tasks, RxJS or stores (which can be based on RxJS) are recommended. The synchronous transport of data to the view should be handled by Signals. Together, RxJS, stores, and Signals are the building blocks for establishing a unidirectional data flow.
The flattening operators in RxJS can also elegantly avoid race conditions. Alternatively or in addition to this, flags can be used to indicate if a request is currently in progress at the backend.
Even if Signals weren’t primarily created to display events, there are cases when you want to react to changes in a Signal. This is the case with framework APIs based on Signals. In addition to the RxJS interop, the rxMethod from the Signal Store can also be used. Another option is the effect/untracked pattern for implementing effects that only react to explicitly named Signals.