@rx-angular/state/effects
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
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.
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.
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
- Functional Creation (_NEW_)
- Class Based (Classic)
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)');
});
});
}
However, the class based approach is still valid and works exactly as before.
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);
ngOnInit() {
// side effect that runs when `windowResize$` emits a value
this.effects.register(fromEvent(window, 'resize'), () => {
console.log('window was resized');
});
// side effect that runs on component destruction
this.effects.registerOnDestroy(() => {
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
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
.
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
.
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:
- Functional Creation (_NEW_)
- Class Based (Classic)
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();
}
}
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);
}
}
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 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';
- Class Based (Classic)
- Functional Creation (_NEW_)
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');
});
}
}
import { rxEffects } from '@rx-angular/state/effects';
import { Component } from '@angular/core';
import { fromEvent } from 'rxjs';
@Component({})
export class MyComponent {
// create `RxEffects` in a single step, no providers anymore
readonly effects = rxEffects();
constructor() {
// use the `register` function as before
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.
- Class Based (Classic)
- Functional Creation (_NEW_)
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');
});
}
}
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');
});
});
}
Manual Unsubscription (Unregister)
The API for manually unregistering a registered effect changed and is now aligned with how effect
s work
in angular. Instead of an id, you now get a callback function in return.
- Class Based (Classic)
- Functional Creation (_NEW_)
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);
}
}
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();
}
}
Effect on Destroy
The API for registering an effect on instance destruction changed and is now aligned with the DestroyRef
s API.
The name changed to just onDestroy
, you now get a callback function in return.
- Class Based (Classic)
- Functional Creation (_NEW_)
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');
});
}
}
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(({ onDestroy }) => {
onDestroy(() => {
console.log('effects instance destroyed');
});
});
}
untilEffect
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.
- login.component.ts
- login.component.spec.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) {}
}
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
describe('LoginComponent', () => {
let service: AuthService;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [LoginComponent],
providers: [AuthService],
});
service = TestBed.inject(AuthService);
fixture = TestBed.createComponent(LoginComponent);
});
it('should login on button click', () => {
// arrange
const button = fixture.debugElement.query(By.css('button'));
const spy = spyOn(service, 'login');
// act
button.nativeElement.click();
// assert
expect(spy).toHaveBeenCalled();
});
});
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.
- login-effects.service.ts
- login-effects.service.spec.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();
}
}
import { TestBed } from '@angular/core/testing';
describe('LoginEffects', () => {
let authService: AuthService;
let effects: LoginEffects;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AuthService, LoginEffects],
});
authService = TestBed.inject(AuthService);
effects = TestBed.inject(LoginEffects);
});
it('should call authService login', () => {
// arrange
const spy = spyOn(authService, 'login');
// act
effects.login();
// assert
expect(spy).toHaveBeenCalled();
});
});
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
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
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
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
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);
}
}