Skip to main content

@rx-angular/state/effects

npm rx-angular CI

A small convenience helper to handle side effects based on Observable inputs.

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

Key features

  • ✅ Simple API to handle observable based side effects
  • ✅ Clean separation of concerns
  • ✅ Automatic subscription cleanup on destroy
  • ✅ 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

Motivation

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

Side effects, especially those involving asynchronous operations like Promises or Observables, often lead to complex code and potential issues such as memory leaks and late subscriber problems.

Side Effects

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

Although they can be triggered by state changes, they should generally operate independently of state.

Pro tip

It’s recommended to avoid direct use of the subscribe API of RxJS to mitigate these issues.

With RxEffects RxAngular introduces a lightweight tool to simplify subscription management, ensure clean and efficient side effect handling without the need to manually subscribe and unsubscribe.

Usage

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

The new functional creation API lets you create and configure `RxEffects` in only one place. Thanks to the new `DestroyRef`, there is no need for manually providing an instance anymore.
Migration Guide

Read the following section for a migration guide explaining how to upgrade your codebase to the new API.

import { rxEffects } from '@rx-angular/state/effects';
import { inject, Component } from '@angular/core';
import { fromEvent } from 'rxjs';

@Component({})
export class MyComponent {
// create and configure `RxEffects` in a single step
readonly effects = rxEffects(({ register, onDestroy }) => {
// side effect that runs when `window resize` emits a value
register(fromEvent(window, 'resize'), () => {
console.log('window was resized');
});

// side effect that runs on component destruction
onDestroy(() => {
console.log('custom cleanup logic (e.g flushing local storage)');
});
});
}

Inline Configuration

rxEffects also provides the possibility for inlining the configuration. This helps you to keep your codebase clean. It accepts a RxEffectsSetupFn which enables you directly use the top level APIs on creation.

import { rxEffects } from '@rx-angular/state/effects';
import { Component } from '@angular/core';
import { fromEvent } from 'rxjs';

@Component({})
export class MyComponent {
// create & setup `RxEffects` in a single step, no providers anymore
readonly effects = rxEffects(({ register }) => {
register(fromEvent(window, 'resize'), () => {
console.log('window was resized');
});
});
}

RxEffects as Service

No Token by default

The new rxEffects creation function does not insert an injection token into the dependency injection tree.

As the new functional API does not register itself into the DI system, you need to wrap rxEffects into a custom service in case you want to share an instance of RxEffects.

effects.service.ts
import { Injectable } from '@angular/core';
import { rxEffects } from '@rx-angular/state/effects';

@Injectable()
export class EffectsService {
// either share the `effects` const or wrap the API
readonly effects = rxEffects();

// share only some APIs if you like
register: typeof this.effects.register = this.effects.register.bind(
this.effects
);
}

Now you can use it via the DI system and interact with the EffectsService.

effects.component.ts
import { Component, inject } from '@angular/core';
import { EffectsService } from './effects.service.ts';

@Component({
providers: [EffectsService],
})
export class EffectsComponent {
private effects = inject(EffectsService);
}

Register Multiple Observables

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

Promises & Schedulers

You can even use it with promises or schedulers:

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

Custom Cancellation (unregister)

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:

import { rxEffects } from '@rx-angular/state/effects';
import { Component } from '@angular/core';
import { fromEvent } from 'rxjs';

@Component({})
export class MyComponent {
// create and configure `RxEffects` in a single step
effects = rxEffects();
resizeEffect = this.effects.register(fromEvent(window, 'resize'), () => {
console.log('window was resized');
});

undoResizeEffect() {
// effect is now unsubscribed
this.resizeEffect();
}
}

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.

note

Note that your subscription ends after an error occurred. If the stream encountered an error once, it is done Read more about how to recover from this in the next section.

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

import { ErrorHandler, Component } from '@angular/core';
import { throwError } from 'rxjs';
import { rxEffects } from '@rx-angular/state/effects';

@Component({
providers: [
{
provide: ErrorHandler,
useValue: {
handleError: (e) => {
sendToSentry(e);
},
},
},
],
})
class MyComponent {
readonly effects = rxEffects(({ register }) => {
// if your effects runs into an error, your custom errorHandler will be informed
register(throwError('E'));
});
}

Retry on error

In order to recover from an error state and keep the side effect alive, you have two options:

import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { retry, catchError, of, exhaustMap, Subject } from 'rxjs';
import { rxEffects } from '@rx-angular/state/effects';

@Component()
class MyComponent {
http = inject(HttpClient);
login$ = new Subject<{ user: string; pass: string }>();

readonly effects = rxEffects(({ register }) => {
register(
this.login$.pipe(
exhaustMap(({ user, pass }) =>
this.http.post('/auth/', { user, pass })
),
// retry when an error occurs
retry()
),
(data) => {
alert(`welcome ${data.user}`);
}
);

register(
this.login$.pipe(
exhaustMap(({ user, pass }) =>
this.http.post('/auth/', { user, pass })
),
// catch the error and return a custom value
catchError((err) => {
return of(null);
})
),
(data) => {
if (data) {
alert(`welcome ${data.user}`);
}
}
);
});
}

Polling Example

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({})
export class ChartComponent {
private ngRxStore = inject(Store);
chartVisible$ = new Subject<boolean>();
chartData$ = this.ngRxStore.select(getListData());

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

readonly effects = rxEffects(({ register }) => {
register(this.pollingTrigger$, () =>
this.ngRxStore.dispatch(refreshAction())
);
});
}

Migrate to new functional API

The new functional API provides a nicer developer experience and aligns with the new Angular APIs recently released. We want to emphasize everyone to use the new functional API. The following examples showcases the key differences and how to migrate from the class based approach to the functional one.

Providers

The beauty of the new functional approach is that it works without providers. This way, you simply use the new creation function rxEffects. Instead of importing RxEffects and putting it into the providers array, you now import rxEffects. The namespace still stays the same.

import { rxEffects } from '@rx-angular/state/effects';
import { RxEffects } from '@rx-angular/state/effects';
import { inject, Component } from '@angular/core';
import { fromEvent } from 'rxjs';

@Component({
// provide `RxEffects` as a local instance of your component
providers: [RxEffects],
})
export class MyComponent {
// inject your provided instance
readonly effects = inject(RxEffects);

constructor() {
// side effect that runs when `windowResize$` emits a value
this.effects.register(fromEvent(window, 'resize'), () => {
console.log('window was resized');
});
}
}

Inline Configurations

The functional approach also allows for inline the configuration. This helps you to keep your codebase clean. rxEffects accepts a RxEffectsSetupFn which enables you directly use the top level APIs on creation.

import { RxEffects } from '@rx-angular/state/effects';
import { inject, Component } from '@angular/core';
import { fromEvent } from 'rxjs';

@Component({
// provide `RxEffects` as a local instance of your component
providers: [RxEffects],
})
export class MyComponent {
// inject your provided instance
readonly effects = inject(RxEffects);

constructor() {
// side effect that runs when `windowResize$` emits a value
this.effects.register(fromEvent(window, 'resize'), () => {
console.log('window was resized');
});
}
}

Manual Unsubscription (Unregister)

The API for manually unregistering a registered effect changed and is now aligned with how effects work in angular. Instead of an id, you now get a callback function in return.

import { RxEffects } from '@rx-angular/state/effects';
import { Component, inject } from '@angular/core';
import { fromEvent } from 'rxjs';

@Component({
providers: [RxEffects],
})
export class MyComponent {
// create and configure `RxEffects` in a single step
effects = inject(RxEffects);
resizeEffect = this.effects.register(fromEvent(window, 'resize'), () => {
console.log('window was resized');
});

undoResizeEffect() {
// effect is now unsubscribed
this.effects.unregister(this.resizeEffect);
}
}

Effect on Destroy

The API for registering an effect on instance destruction changed and is now aligned with the DestroyRefs API. The name changed to just onDestroy, you now get a callback function in return.

import { RxEffects } from '@rx-angular/state/effects';
import { Component, inject } from '@angular/core';
import { fromEvent } from 'rxjs';

@Component({
providers: [RxEffects],
})
export class MyComponent {
// create and configure `RxEffects` in a single step
effects = inject(RxEffects);

constructor() {
this.effects.registerOnDestroy(() => {
console.log('effects instance destroyed');
});
}
}

untilEffect

dropped

The untilEffect top level API was dropped. You would need to build your own workaround for this.

Testing

The rxEffects API is designed to not force developers to interact with its instance in order to properly test your components. Side effects always have a trigger and a side effect function, rxEffects only acts as a glue between those two. Typically you want to test either the trigger or the side effect function.

Basic Testing

Take a look at the following example where we want test if the auth services' login method is called when the login button is clicked. Instead of interacting with rxEffects directly, we are testing the trigger and the side effect function.

/src/login.component.ts
import { rxEffects } from '@rx-angular/state/effects';
import { Component } from '@angular/core';
import { AuthService } from './auth.service.ts';

@Component({
selector: 'login',
template: '<button (click)="login.next()">Login</button>',
})
export class MyComponent {
readonly login = new Subject<void>();
// create & setup `RxEffects` in a single step, no providers anymore
readonly effects = rxEffects(({ register }) => {
register(this.login.pipe(exhaustMap(() => this.authService.login())));
});

constructor(private authService: AuthService) {}
}

Service Based Testing

As rxEffects is no DI token, but a creation function, you cannot inject it into your TestBed. As already explained in the RxEffects as Service section, in order to overcome this, you need to wrap your rxEffects into a service.

/src/login-effects.service.ts
import { rxEffects } from '@rx-angular/state/effects';
import { Injectable } from '@angular/core';

@Injectable()
export class LoginEffects {
private login$ = new Subject<void>();
// create & setup `RxEffects` in a single step, no providers anymore
private readonly effects = rxEffects(({ register }) => {
register(this.login$.pipe(exhaustMap(() => this.authService.login())));
});

constructor(private authService: AuthService) {}

login() {
this.login$.next();
}
}

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 MyComponent {
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 MyComponent {
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 MyComponent {
private effect$ = interval(1000);
private effectId: number;

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

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

Read More