Skip to main content

@rx-angular/state/actions

npm rx-angular CI

RxActions is a powerful yet simple tool to manage action throughout your application. It can be seen as the glue between user based events and your applications state.

Key features

  • ✅ No boilerplate
  • ✅ Minimal memory footprint through a Proxy object and lazy initialization
  • ✅ Automatic subscription handling
  • ✅ Clean separation of concerns
  • ✅ Supports imperative & reactive code styles

Install

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

Motivation

Actions are an essential part of any state management system. They represent unique events from e.g. user interactions, external system events or device APIs. From a pure technical perspective, actions are the triggers for state changes and side effect executions. The @rx-angular/state/actions package helps to reduce & streamline your code used to create composable action streams.

It is best suited to be used in combination with RxState and/or RxEffects but can also be used in a standalone way.

note

Actions represent unique events that happen in your application.

Usage

Migration Guide

We have transitioned to a new functional API.

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

RxActions are instantiated by the creation function rxActions, imported from the @rx-angular/state/actions entrypoint.

The following example shows how to use rxActions in a standalone way for a simple login component. The interface for our actions will be { login: { username: string; password: string; } }.

Dispatch & React to actions

Note how rxActions transforms the action interface into a dispatchable action login() and a readable stream, login$. This way it makes it easy to dispatch it from the template and to glue it to other building blocks.

src/login.component.ts
import { rxActions } from '@rx-angular/state/actions';
import { exhaustMap } from 'rxjs';

@Component({
template: `
<input placeholder="username" #username />
<input type="password" placeholder="password" #password />
<button
(click)="
actions.login({
username: username.value,
password: password.value
})
"
>
Login
</button>
`,
})
class LoginComponent {
actions = rxActions<{ login: { username: string; password: string } }>();

constructor(private service: AuthService) {
this.actions.login$
.pipe(exhaustMap((credentials) => this.service.login(credentials)))
.subscribe();
}
}

Handling side effects on event emission

In this example we use the on shorthand to trigger a side effect every time the event it emitted. It returns a function which when called stops firing the side effect.

on shorthand

rxActions also has a built in solution to easily apply side effects on actions. For every property in your actions type, you will get the on shorthand, e.g. { refresh: void } will also expose the onRefresh method.

src/login.component.ts
import { DOCUMENT } from '@angular/common';
import { rxActions } from '@rx-angular/state/actions';
import { exhaustMap } from 'rxjs';

@Component({
template: `
<input placeholder="username" #username />
<input type="password" placeholder="password" #password />
<button
(click)="
actions.login({
username: username.value,
password: password.value
})
"
>
Login
</button>
`,
})
class LoginComponent {
actions = rxActions<{ login: { username: string; password: string } }>();

private loginEffect = this.actions.onLogin(
(credentials$) =>
credentials$.pipe(
exhaustMap((credentials) => this.service.login(credentials))
),
() => this.doc.defaultView.alert('successfully logged in')
);
constructor(
private service: AuthService,
@Inject(DOCUMENT) private doc: Document
) {}
}

Usage in a service to handle data fetching

In this example we see how to use rxActions to handle data fetching. We can see how to apply behaviour onto the refresh calls (exhaustMap the HTTP requests). We also use a signal to hold our state of fetched movies.

src/movie.service.ts
import { signal } from '@angular/core';
import { rxActions } from '@rx-angular/state/actions';
import { exhaustMap } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MovieService {
private movieResource = inject(MovieResource);
private actions = rxActions<{ refresh: void }>();
movies = signal<Movie[]>([]);

private refreshEffect = this.actions.onRefresh(
// data refresh with applied behaviour
(refresh$) =>
refresh$.pipe(exhaustMap(() => this.movieResource.getMovies())),
// set the value to the state
(movies) => this.movies.set(movies)
);

refresh() {
this.actions.refresh();
}
}

Unsubscribing from events programmatically

The return value of the on shorthand is a cleanup function. Calling it will stop the effects execution.

src/movie.service.ts
import { signal } from '@angular/core';
import { rxActions } from '@rx-angular/state/actions';
import { exhaustMap } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MovieService {
private movieResource = inject(MovieResource);
private actions = rxActions<{ refresh: void }>();
movies = signal<Movie[]>([]);

private refreshEffect = this.actions.onRefresh(
// data refresh with applied behaviour
(refresh$) =>
refresh$.pipe(exhaustMap(() => this.movieResource.getMovies())),
// set the value to the state
(movies) => this.movies.set(movies)
);

refresh() {
this.actions.refresh();
}

disable() {
this.refreshEffect();
}
}

Transformations

Often we process Events from the template and occasionally also trigger those channels in the class programmatically.

This leads to a cluttered codebase as we have to consider first the value in the event which leads to un necessary and repetitive code in the template. This is also true for the programmatic usage in the component class or a service.

By using the transformations API, we can preconfigure how our input events are mapped to actual values in a single place.

You can write your own transforms, leverage Browser APIs like String and Boolean or use the predefined functions.

Use existing transform functions

The existing transform functions are:

  • preventDefault -> calls preventDefault on a passed event
  • stopPropagation -> calls stopPropagation on a passed event
  • preventDefaultStopPropagation -> calls both of the above
  • eventValue -> extracts the value from an input event

Here we see how to use an action transforms. This concept is similar to Input transforms. The logic is placed in a single place and transforms the event before emission.

src/list.component.ts
import { rxActions, eventValue } from '@rx-angular/state/actions';

@Component({
// takes a DOM Event
template: `<input name="search" (change)="actions.search($event)" />`,
})
class ListComponent {
actions = rxActions<{ search: string }>(({ transforms }) =>
// if event is forwarded pluck the value `e?.target?.value` else forward the value as is
transforms({ search: eventValue })
);
}

Use custom transform functions

src/greet.component.ts
import { Component } from '@angular/core';
import { rxActions } from '@rx-angular/state/actions';

@Component({
template: `
<input name="name" (input)="ui.greet($event.target.value)" />
<div>{{ ui.greet$ | async }}</div>
`,
/**/
})
export class GreetComponent {
ui = rxActions<{ greet: string }>(({ transforms }) =>
transforms({
greet: (v) => `Hello ${v}`,
})
);
}

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.

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

import { rxActions } from '@rx-angular/state/actions';
src/login.component.ts
import { RxActionFactory } from '@rx-angular/state/actions';
import { exhaustMap } from 'rxjs';

@Component({
template: `
<input placeholder="username" #username />
<input type="password" placeholder="password" #password />
<button
(click)="
actions.login({
username: username.value,
password: password.value
})
"
>
Login
</button>
`,
// provide `RxActionFactory` as local instance of your component
providers: [RxActionFactory]
})
export class LoginComponent {
actions = this.actionFactory.create();

constructor(
private service: AuthService
private actionFactory: RxActionFactory<{ login: { username: string; password: string } }>
) {
this.actions.login$
.pipe(exhaustMap((credentials) => this.service.login(credentials)))
.subscribe();
}
}

Testing

The following section shows different examples on how to test angular building blocks that use rxActions. The components and services tested here, all are described in the examples before.

Test usage in component to handle UI interaction

src/login.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

describe('LoginComponent', () => {
let fixture: ComponentFixture<LoginComponent>;
let service: AuthService;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [LoginComponent],
providers: [AuthService],
}).compileComponents();
fixture = TestBed.createComponent(LoginComponent);
service = TestBed.inject(AuthService);
fixture.detectChanges();
});

it('login on form submit', () => {
// arrange
const username = fixture.debugElement.query(By.css('input:first-child'));
const password = fixture.debugElement.query(
By.css('input[type="password"]')
);
const btn = fixture.debugElement.query(By.css('button'));
const loginSpy = jest.spyOn(service, 'login');
username.nativeElement.value = 'user';
password.nativeElement.value = 'pwd';

// act
fixture.detectChanges();
btn.nativeElement.click();

// assert
expect(loginSpy).toHaveBeenCalled();
});
});

Testing usage in a service to handle data fetching

To test actions in a service most of the time mock logic is required.

src/movie.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs';
import { MovieResource } from './movie.resource';
import { MovieService } from './movie.service';

// MovieResource Mock helper

export class MovieResourceMock {
httpRequest = new Subject<Movie[]>();
getMovies(): Observable<Movie[]> {
return this.httpRequest.pipe(take(1));
}
}

// Test code ==========

describe('MovieService', () => {
let service: MovieService;
let resource: MovieResourceMock;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
MovieService,
{ provide: MovieResource, useClass: MovieResourceMock },
],
}).compileComponents();
service = TestBed.inject(MovieService);
resource = TestBed.inject(MovieResource) as any;
});

it('should fetch movies on refresh', () => {
// arrange
const movies = [{ id: '1' }];
// act
service.refresh();
resource.httpResponse.next(movies);
// assert
expect(service.movies()).toEqual(movies);
});
});

Test handling side effects on event emission

src/login.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';

class AuthServiceMock {
login(credentials: { username: string; password: string }) {
return of(true);
}
}

describe('LoginComponent', () => {
let fixture: ComponentFixture<LoginComponent>;
const documentMock = {
defaultView: {
alert: (v) => v,
},
};

beforeEach(() => {
TestBed.configureTestingModule({
imports: [LoginComponent],
providers: [{ provide: AuthService, useClass: AuthServiceMock }],
})
.overrideComponent(LoginComponent, {
set: {
providers: [
{
provide: DOCUMENT,
useValue: documentMock,
},
],
},
})
.compileComponents();
fixture = TestBed.createComponent(LoginComponent);
fixture.detectChanges();
});

it('should alert success after login', () => {
// arrange
const username = fixture.debugElement.query(By.css('input:first-child'));
const password = fixture.debugElement.query(
By.css('input[type="password"]')
);
const btn = fixture.debugElement.query(By.css('button'));
const alertSpy = jest.spyOn(documentMock.defaultView, 'alert');
username.nativeElement.value = 'user';
password.nativeElement.value = 'pwd';

// act
fixture.detectChanges();
btn.nativeElement.click();

// assert
expect(alertSpy).toHaveBeenCalled();
});
});

Test unsubscribing from events programmatically

src/movie.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs';
import { MovieResource } from './movie.resource';
import { MovieService } from './movie.service';

// MovieResource Mock helper

export class MovieResourceMock {
httpRequest = new Subject<Movie[]>();
getMovies(): Observable<Movie[]> {
return this.httpRequest.pipe(take(1));
}
}

// Test code ==========

describe('MovieService', () => {
let service: MovieService;
let resource: MovieResourceMock;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
MovieService,
{ provide: MovieResource, useClass: MovieResourceMock },
],
}).compileComponents();
service = TestBed.inject(MovieService);
resource = TestBed.inject(MovieResource);
});

it('should stop fetching when autoRefresh has stopped', () => {
// arrange
const getMoviesSpy = jest.spyOn(resource, 'getMovies');
resource.httpRequest.next(movies);
// act
service.refresh();
// assert
expect(service.movies()).toEqual(movies);
});
});

Test transform functions

src/greet.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

describe('GreetComponent', () => {
let fixture: ComponentFixture<GreetComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [GreetComponent],
});
fixture = TestBed.createComponent(GreetComponent);
fixture.detectChanges();
});

it('should greet me', () => {
// arrange
const input = fixture.debugElement.query(By.css('input'));
const div = fixture.debugElement.query(By.css('div'));
input.nativeElement.value = 'me';
// act
(input.nativeElement as HTMLInputElement).dispatchEvent(
new InputEvent('input')
);
fixture.detectChanges();
// assert
expect(div.nativeElement.textContent.trim()).toBe('Hello me');
});
});