Managing Side Effects with Component Action Streams
Published: August 22, 2019In any SPA, side effects are where you'll find a lot of edge cases hiding. What happens if a user makes a new request while an existing one is already in-flight? How do we handle retry? What about polling for changes? What if the user leaves the page right after clicking the search button?
A common pattern that doesn't handle these edge cases looks something like this:
<button (click)="fetch(1)">Fetch 1</button>
<button (click)="fetch(2)">Fetch 2</button>
<div>{{ thing?.name }}</div>
@Component(...)
export class MyComponent {
public thing: Thing;
constructor(private thingService: ThingService) {}
public fetch(id: number) {
// ThingService makes an HTTP request to the server.
this.thingService.get(`/thing/${id}`).subscribe((thing) => {
this.thing = thing;
});
}
}
With the setup above, we're just firing off a request every time one of the buttons is clicked. In a production environment, there's no guarantee that your requests will come back in the order in which they were made. In the code sample above, there's no guarantee that this.thing
will be set to the latest response.
Luckily, all of the tools you need to manage side effects easily and correctly are already baked into Angular. In this post, I'll show how to create action streams inside your components. If you've worked with @ngrx/effects
, this should look familiar. These action streams are essentially small-scale versions of @ngrx/effects
effect classes. Once you have your action stream in place, you can use RxJS operators to easily handle complex data flows and concurrency.
Example
The main idea is to funnel your side effect triggers (actions) into an RxJS stream. In our example above, we directly triggered a network request when either of the fetch buttons is clicked, leading to the ordering bug. Since each request was sent immediately, there was no decision point where we could decide what to do with any pending requests. The action stream gives us a place to make those decisions.
So what does a fixed version look like?
<button (click)="fetch(1)">Fetch 1</button>
<button (click)="fetch(2)">Fetch 2</button>
<div>{{ thing?.name }}</div>
@Component(...)
export class MyComponent {
public thing: Thing;
private actions$ = new BehaviorSubject<number | null>(null);
constructor(private thingService: ThingService) {}
public ngOnInit() {
this.actions$.pipe(
filter(Boolean), // Filter out the initial null value.
switchMap((id) => this.thingService.get(id).pipe(catchError(() => of())),
untilDestroyed(this) // From ngx-take-until-destroy
).subscribe((thing) => {
this.thing = thing;
});
}
// This is required by ngx-take-until-destroy
public ngOnDestroy() {}
public fetch(id: number): Observable<Thing> {
this.actions$.next(id);
}
}
The difference here is that instead of directly making HTTP requests when a button is clicked, we're sending an action into our component's action stream. By running the user actions through a BehaviorSubject
, we're giving ourselves that decision point we talked about earlier. In this case, we're using switchMap
to cancel any pending requests. No matter how quickly or how many times the user clicks back and forth between the buttons, we guarantee that the subscribe callback will be invoked with the response from the most recent request.
Subject vs. BehaviorSubject
Both Subject
s and BehaviorSubject
s can be used to create an action stream. The choice depends on your requirements, and in the example above, we could have just as easily used a Subject
. In real world apps, there are a few common scenarios that usually lead me to use a BehaviorSubject
.
Only changing part of the action
It's common for your actions to be based off of the immediately preceding action. Think about changing the result page in a search interface. You want to keep the current filter settings and only update the page. BehaviorSubject
s remember their last value, which you can access via the public .value
property.
Here's how you'd implement a page change with a BehaviorSubject
-based action stream:
public changePage(page: number) {
this.actions$.next({
...this.actions$.value, // Keep the existing filters.
page
});
}
Retry
If a request fails, you might want to show your user a retry button. Adding retry functionality is trivial with a BehaviorSubject
. You could use a Subject
and do some bookkeeping yourself to track the last request, but I'd rather let RxJS do it for me.
public retry() {
this.actions$.next(this.actions$.value);
}
Initial requests
BehaviorSubject
s emit their current value when they're subscribed to, so an easy way to make an initial request is to initialize the BehaviorSubject
with an initial action. This is common with searchable interfaces that should make a default search request when the component first renders.
private actions$ = new BehaviorSubject<SearchArgs>({
page: 0,
searchTerm: '',
order: 'asc'
});
Future improvements
Our example component has a lot going on: it manages the action stream, invokes the ThingService
, and manages state. This article is long enough, but in future articles, I'll show how you can clean things up by factoring state management out to a store and moving the action stream into a dedicated service. In this particular case, you could also be using the async
pipe instead of storing a thing
variable, but in most real-world cases, your state will be in a separate service and you'll need to manually subscribe to your action stream.