Skip to main content

@rx-angular/state/effects

npm rx-angular CI

A small typed convenience helper to handle side effects and Observable subscriptions.

@rx-angular/state/effects is a small set of helpers designed to handle effects.

Key features

  • ✅ No boilerplate
  • ✅ Easy to test
  • ✅ Clean separation of concerns
  • ✅ Slim and handy APIs
  • ✅ Auto-cleanup on destroy
  • ✅ Effect interoperability
  • ✅ Handlers for imperative code styles

Demos:

Install

npm install --save @rx-angular/state
# or
yarn add @rx-angular/state

Update

If you are using @rx-angular/state already, please consider upgrading with the @angular/cli update command in order to make sure all provided code migrations are processed properly.

ng update @rx-angular/state
# or with nx
nx migrate @rx-angular/state

Documentation

Motivation

rx-angular--state--effects--motivation--michael-hladky

Most of the side effects are related to rendering and change detection and done in the template by building blocks like:

  • pipes
  • directives
  • component bindings

Some of the side effects are not related to the template and need to get handled in the component. For for async effect's like Promise or Observable it requires to maintain a cancellation logic.

Pro Tip: In general, it's best to avoid the direct use of the subscribe API of RxJS at all.

It may sound weird, as I'm pretty sure you are used to handle your subscriptions. You most probably store the Subscription object, add a takeUntil to hook it into the component lifecycle and avoid memory leaks etc. Maybe even hacks where you subscribe to one Observable just to next into another subject.

In RxAngular we found ways to avoid the subscribe API and in addition handle all of the above edge cases and more. This is the hidden secret of why all parts of RxAngular fit together so well.

However, sometimes we have to subscribe in the component to handle reactive side effects. This leads to bloated code and potential risk of a memory leak, late subscriber and so on.

In the context of state management every piece of code which does not manipulate, transform or read state can be considered as side effect.

Side effects can be triggered by state changes but don't depend on state.

Side effects (most of the time coming from subscriptions) always yield the potential of a memory leak if not cleaned up correctly. Like local state, local side-effects need to be coupled to the lifecycle of the component. To accomplish this, we need to make sure to clean up every side effect in the OnDestroy method. Here we can de-reference local variables and unsubscribe open subscriptions.

With RxEffects RxAngular introduces another light weight tool only designed to manage side-effects.

Problem

Let's get the problem it solves into code so we can refactor it.

We start with the side effect and 2 ways to execute it:

@Component({
// ...
})
export class FooComponent {
// The side effect (`console.log`)
private runSideEffect = (num: number) => console.log('number: ' + num);
// The interval triggers our function including the side effect
private effect$ = interval(1000);

constructor() {
// [#1st approach] The subscribe's next callback it used to wrap and execute the side effect
effect$.subscribe(this.runSideEffect);

// [#2nd approach] `tap` is used to wrap and execute the side effect
effect$.pipe(tap(this.runSideEffect)).subscribe();
}
}

As we introduced a memory leak we have to setup some boilerplate code to handle the cleanup logic:

@Component({
// ...
})
export class FooComponent implements OnDestroy {
// ⚠ Notice: The destroy hook must be reactive to use `takeUntil`
private readonly destroy$ = new Subject<void>();

constructor() {
effect$
.pipe(
takeUntil(this.destroy$)
// ⚠ Notice: Don't put any operator after takeUntil to avoid potential subscription leaks
)
.subscribe(runSideEffect);
}

ngOnDestroy(): void {
// ⚠ Notice: Never forget to cleanup the subscription
this.destroy$.next();
}
}

There are already a couple of things that are crucial:

  • using the right Subject
  • unsubscribe on destroy
  • having the takeUntil operator as last operator in the chain

Another way would be using the subscription to run the cleanup logic.

@Component({
// ...
})
export class FooComponent implements OnDestroy {
// ⚠ Notice: The created subscription must be stored to `unsubscribe` later
private readonly subscription: Subscription;

constructor() {
// ⚠ Notice: Never forget to store the subscription
this.subscription = effect$.subscribe(runSideEffect);
}

ngOnDestroy(): void {
// ⚠ Notice: Never forget to cleanup the subscription
this.subscription.unsubscribe();
}
}

Solution

In RxAngular we think the essential problem here is the call to subscribe itself. All Subscriptions need to get unsubscribed manually which most of the time produces heavy boilerplate or even memory leaks if ignored or did wrong. Like RxState, RxEffects is a local service provided by a component and thus tied to the components life cycle. We can manage Observables as reactive triggers for side effects or manage Subscriptions which internally hold side effects. To also provide an imperative way for developers to unsubscribe from the side effect register returns an "asyncId" similar to setTimeout. This can be used later on to call unregister and pass the async id retrieved from a previous register call. This stops and cleans up the side effect when invoked.

As an automatism any registered side effect will get cleaned up when the related component is destroyed.

Using RxEffect to maintain side-effects

@Component({
// ...
providers: [RxEffects],
})
export class FooComponent {
constructor(effects: RxEffects) {
effects.register(obs$, doSideEffect);
}
}

⚠ Notice: Avoid calling register, unregister , subscribe inside the side-effect function. (here named doSideEffect)

Impact

rx-angular--state--effects--motivation-process-diagramm--michael-hladky

Compared to common approaches RxEffects does not rely on additional decorators or operators. In fact, it removes the necessity of the subscribe.

This results in less boilerplate and a good guidance to resilient and ergonomic component architecture. Furthermore, the optional imperative methods help to glue third party libs and a mixed but clean code style in Angular.

Concepts

Let's have some fundamental thoughts on the concept of side effects and their reactive handling. Before we get any further, let's define two terms, side effect and pure function.

Referentially transparent

rx-angular--state--effects--concept-referentially-transparent--michael-hladky A function is referentially transparent if:

  • it is pure (output must be the same for the same inputs)
  • it's evaluation must have no side effects

Pure function

rx-angular--state--effects--concept-pure-function--michael-hladky

A function is called pure if:

  • Its return value is the same for the same arguments, e.g. function add(a, b) { return a + b}
  • Its executed internal logic has no side effects

Side effect

rx-angular--state--effects--concept-side-effect-free--michael-hladky

A function has a side effect if:

  • There's a mutation of local static variables, e.g. this.prop = value
  • Non-local variables are used

Examples

Let's look at a couple of examples that will make the above definitions easier to understand.

let state = false;
sideEffectFn();

function sideEffectFn() {
state = true;
}
  • mutable reference arguments get passed
let state = { isVisible: false };
let newState = sideEffectFn(state);

function sideEffectFn(oldState) {
oldState.isVisible = true;
return oldState;
}
  • I/O is changed
let state = { isVisible: false };
sideEffectFn(state);

function sideEffectFn(state) {
console.log(state);
// or
this.render(state);
}

As a good rule of thumb, you can consider every function without a return value to be a side effect.

Anatomy

rx-angular--state--effects--motivation-building-blocks--michael-hladky

Yet, essentially, a side effect always has 2 important parts associated with it:

  • the trigger
  • the side-effect logic

In the previous examples, the trigger was the method call itself like here:

@Component({
// ...
providers: [RxEffects],
})
export class FooComponent {
private runSideEffect = console.log;
private effect$ = interval(1000).pipe(tap(this.runSideEffect));

constructor(effects: RxEffects) {
effects.register(this.effect$);
}
}

We can also set a value emitted from an Observable as a trigger. Thus, you may use a render call or any other logic executed by the trigger as the side-effect logic.

@Component({
// ...
providers: [RxEffects],
})
export class FooComponent {
private runSideEffect = console.log;
private effect$ = interval(1000);

constructor(effects: RxEffects) {
effects.register(this.effect$, this.runSideEffect);
}
}

The subscription handling and cleanup is done automatically under the hood. However, if we want to stop a particular side effect earlier we can do the following:

@Component({
// ...
providers: [RxEffects],
})
export class FooComponent {
private effect$ = interval(1000);
private effectId: number;

constructor(effects: RxEffects) {
this.effectId = effects.register(this.effect$, console.log);
}

stop() {
this.effects.unregister(this.effectId);
}
}

Install

npm install --save @rx-angular/state
# or
yarn add @rx-angular/state

Update

If you are using @rx-angular/state already, please consider upgrading with the @angular/cli update command in order to make sure all provided code migrations are processed properly.

ng update @rx-angular/state
# or with nx
nx migrate @rx-angular/state

Usage

rx-angular--state--effects--motivation-when-to-use--michael-hladky

In this example we have a chart in our UI which should display live data of a REST API ;). We have a small handle that shows and hides the chart. To avoid data fetching when the chart is not visible we connect the side effect to the toggle state of the chart.

@Component({
// ...
providers: [RxEffects],
})
export class FooComponent {
chartVisible$ = new Subject<boolean>();
chartData$ = this.ngRxStore.select(getListData());

pollingTrigger$ = this.chartVisible$.pipe(
switchMap((isPolling) => (isPolling ? interval(2000) : EMPTY))
);

constructor(private ngRxStore: Store, private effects: RxEffects) {
effects.register(this.pollingTrigger$, () =>
this.ngRxStore.dispatch(refreshAction())
);
}
}

Advanced examples

The register method can also be combined with tap or even subscribe:

effects.register(obs$, doSideEffect);
// is equivalent to
effects.register(obs$.pipe(tap(doSideEffect)));
// is equivalent to
effects.register(obs$.subscribe(doSideEffect));
// is equivalent to
effects.register(obs$, { next: doSideEffect }); // <- you can also tap into error or complete here

You can even use it with promises or schedulers:

effects.register(fetch('...'), doSideEffect);
effects.register(animationFrameScheduler.schedule(action));

All registered effects are automatically unsubscribed when the component is destroyed. If you wish to cancel a specific effect earlier, you can do this either declaratively (obs$.pipe(takeUntil(otherObs$))) or imperatively using the returned effect ID:

this.effectId = this.effects.register(obs$, doSideEffect);

// later
this.effects.unregister(this.effectId); // doSideEffect will no longer be called

Error handling

If an error is thrown inside one side-effect callback, other effects are not affected. The built-in Angular ErrorHandler gets automatically notified of the error, so these errors should still show up in Rollbar reports.

However, there are additional ways to tweak the error handling.

We can hook into this process by providing a custom error handler:

import { ErrorHandler } from '@angular/core';

const customErrorHandler: ErrorHandler = {
handleError: jest.fn()
};

@NgModule({
declarations: [AnyComponent],
providers: [
{
provide: ErrorHandler,
useValue: customErrorHandler
}
]
});
// ...