@rx-angular/state/actions
This package provides a quick way to create an action channel and some configuration options.
Key features
- ✅ Fully Typed
- ✅ No-Boilerplate
- ✅ Configurable transformations to have lines in the template
- ✅ Minimal memory footprint through a Proxy object and lazy initialization
Demos:
Install
npm install --save @rx-angular/state
# or
yarn add @rx-angular/state
Resources
Example applications: A demo application is available on GitHub.
Motivation
Actions are a fundamental part of state management and reactive systems. The @rx-angular/state library provides a powerful set method, but in certain scenarios, you might need to add specific behaviors to your user input or incoming events.
Typically, Subjects are used to implement this functionality. However, in larger applications, this approach often results in code cluttered with Subjects, leading to increased complexity.
That's where @rx-angular/state/actions comes in. It allows you to create observable action streams in a clean, maintainable, and less error-prone way, thus minimizing the amount of boilerplate code required.
RxAngular Actions
This package helps to reduce code used to create composable action streams. It mostly is used in combination with state management libs to handle user interaction and backend communication.
Setup
The coalescing features can be used directly from the cdk
package or indirectly through the state
package.
To do so, install the cdk
package and, if needed, the packages depending on it:
- Install
@rx-angular/state
npm i @rx-angular/state
# or
yarn add @rx-angular/state
Basic usage
By using RxAngular Actions we can reduce the boilerplate significantly, to do so we can start by thinking about the specific section their events and event payload types:
interface Actions {
refreshUser: string | number;
refreshList: string | number;
refreshGenres: string | number;
}
Next we can use the typing to create the action object:
const actionsFactory = new RxActionFactory<Actions>();
const actions = actionsFactory.create();
The object can now be used to emit operations over setters:
actions.refreshUser(value);
actions.refreshList(value);
actions.refreshGenres(value);
The emitted operations can be received over observable properties:
const refreshUser$ = actions.refreshUser$;
const refreshList$ = actions.refreshList$;
const refreshGenres$ = actions.refreshGenres$;
If there is the need to make a combined operation you can also select multiple operations and get their emissions in one stream:
const refreshUserOrList$ = concat(actions.refreshUser$, actions.refreshList$);
Operations in components
In components/templates we can use operations to map user interaction as well as programmatic to effects or state changes. This reduces the component class code as well as template.
In addition, we can use it as a shorthand in the template and directly connect to action dispatching in the class.
interface UiActions {
submitBtn: void;
searchInput: string;
}
@Component({
template: `
<input (input)="ui.searchInput($event.target.value)" /> Search for:
{{ ui.searchInput$ | async }}<br />
<button (click)="ui.submitBtn()">
Submit<button>
<br />
<ul>
<li *ngFor="let item of list$ | async as list">{{ item }}</li>
</ul>
</button>
</button>
`,
providers: [RxState, RxActionFactory],
})
class Component {
ui = this.factory.create<UiActions>({ searchInput: (e) => e });
list$ = this.state.select('list');
submittedSearchQuery$ = this.ui.submitBtn$.pipe(
withLatestFrom(this.ui.searchInput$),
map(([_, search]) => search),
debounceTime(1500)
);
constructor(
private state: RxState<State>,
private factory: RxActionFactory<UiActions>,
globalState: StateService
) {
this.connect('list', this.globalState.refreshGenres$);
this.state.hold(this.submittedSearchQuery$, this.globalState.refreshGenres);
// Optional reactively:
// this.globalState.connectRefreshGenres(this.submittedSearchQuery$);
}
}
Advanced usage
import { Component } from '@angular/core';
import { Subject } from 'rxjs';
import { withLatestFrom, switchMap } from 'rxjs/operators';
@Component({
selector: 'my-component',
template: `
<input (input)="searchInput($event?.target?.value)" /> Search for:
{{ search$ | async }}<br />
<button (click)="submitBtn()">Submit</button>
<br />
<ul>
<li *ngFor="let item of list$ | async">{{ item }}</li>
</ul>
`,
})
export class MyComponent {
private _submitBtn = new Subject<void>();
private _searchInput = new Subject<string>();
submitBtn() {
this._submitBtn.next();
}
get submitBtn$() {
return this._submitBtn.asObservable();
}
searchInput(value: string) {
this._searchInput.next(value);
}
get search$() {
return this._searchInput.asObservable();
}
list$ = this.submitBtn$.pipe(
withLatestFrom(this.search$),
switchMap(([_, searchString]) => this.api.query(searchString))
);
constructor(private api: API) {}
}
Just look at the amount of code we need to write for only 2 observable ui events.
Downsides:
- Boilerplate for setter and getter
- Transformations spreaded over class and template
- Manual typing ov subjects and streams
- No central place for UI trigger
Imagine we could have configurable functions that return all UI logic typed under one object.
import { RxActionFactory } from '@rx-angular/state/actions';
interface UiActions {
submitBtn: void;
searchInput: string;
}
@Component({
template: `
<input (input)="ui.searchInput($event)" /> Search for:
{{ ui.search$ | async }}<br />
<button (click)="ui.submitBtn()">Submit</button>
<br />
<ul>
<li *ngFor="let item of list$ | async as list">{{ item }}</li>
</ul>
`,
providers: [RxActionFactory],
})
export class Component {
ui = this.factory.create({
searchInput: (e) => (e.target ? e.target.value : ''),
});
list$ = this.ui.submitBtn$.pipe(
withLatestFrom(this.ui.search$),
switchMap(([_, searchString]) => this.api.query(searchString))
);
constructor(private api: API, private factory: RxActionFactory<UiActions>) {}
}
Using transforms
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.
To ease this pain we can manage this login with transforms
.
You can write you own transforms or use the predefined functions:
- preventDefault
- stopPropagation
- preventDefaultStopPropagation
- eventValue
import { Component } from '@angular/core';
import { RxState, RxActionFactory } from '@rx-angular/state';
import { StateService } from './state.service';
import { withLatestFrom, map, debounceTime } from 'rxjs/operators';
interface UiActions {
submitBtn: void;
searchInput: string;
}
@Component({
selector: 'my-component',
template: `
<input (input)="ui.searchInput($event)" /> Search for:
{{ ui.searchInput$ | async }}<br />
<button (click)="ui.submitBtn()">Submit</button>
<br />
<ul>
<li *ngFor="let item of list$ | async">{{ item }}</li>
</ul>
`,
providers: [RxState, RxActionFactory],
})
export class MyComponent {
ui = this.factory.create<UiActions>({ searchInput: eventValue });
list$ = this.state.select('list');
submittedSearchQuery$ = this.ui.submitBtn$.pipe(
withLatestFrom(this.ui.searchInput$),
map(([_, search]) => search),
debounceTime(1500)
);
constructor(
private state: RxState<State>,
private factory: RxActionFactory<UiActions>,
private globalState: StateService
) {
this.state.connect('list', this.globalState.refreshGenres$);
this.state.hold(this.submittedSearchQuery$, this.globalState.refreshGenres);
// Optional reactively:
// this.globalState.connectRefreshGenres(this.submittedSearchQuery$);
}
}
Usage for global services
In services, it comes in handy to have a minimal typed action system. This helps to have them composable for further optimizations. Furthermore, we can still expose setters to trigger actions the imperative way.
interface State {
genres: MovieGenreModel[];
}
interface Actions {
refreshGenres: string | number;
}
@Injectable({
providedIn: 'root',
})
export class StateService extends RxState<State> {
private actions = new RxActionFactory<Actions>().create();
genres$ = this.select('genres');
constructor(private tmdb2Service: Tmdb2Service) {
super();
this.connect(
'genres',
this.actions.refreshGenres$.pipe(exhaustMap(this.tmdb2Service.getGenres))
);
}
refreshGenres(genre: string | number): void {
this.actions.refreshGenres(genre);
}
// Optionally the reactive way
connectRefreshGenres(genre$: Observable<string | number>): void {
this.hold(genre$, (genre) => this.actions.refreshGenres(genre));
}
}