@rx-angular/state/actions
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.
Actions represent unique events that happen in your application.
Usage
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; } }
.
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.
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.
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.
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.
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.
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
-> callspreventDefault
on a passed eventstopPropagation
-> callsstopPropagation
on a passed eventpreventDefaultStopPropagation
-> calls both of the aboveeventValue
-> 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.
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
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';
- Class Based (deprecated)
- Functional API (NEW)
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();
}
}
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>
`,
})
export class LoginComponent {
actions = rxActions<{ login: { username: string; password: string } }>();
constructor(private service: AuthService) {
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
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.
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
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
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
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');
});
});