MiniRx Signal Store is in the making...
- 🤓 See the Signal Store RFC on GitHub discussions: https://github.com/spierala/mini-rx-store/discussions/188
- 🤓 Signal Store Pull Request on GitHub: https://github.com/spierala/mini-rx-store/pull/195
- A pre-release version can be loaded from npm: https://www.npmjs.com/package/@mini-rx/signal-store
What can we expect from MiniRx Signal Store?
- Signal Store is an Angular-only state management library
- Signal Store embraces Angular Signals and leverages Modern Angular APIs internally
- Signal Store is based on the same great concept as the original MiniRx Store
- Manage global state at large scale with the Store (Redux) API
- Manage global state with a minimum of boilerplate using Feature Stores
- Manage local component state with Component Stores
- MiniRx always tries to find the sweet spot between powerful, simple and lightweight
- Signal Store implements and promotes new Angular best practices:
- Signals are used for (synchronous) state
- RxJS is used for events and asynchronous tasks
- Signal Store helps to streamline your usage of RxJS and Signals: e.g.
connect
andrxEffect
understand both Signals and Observables - Simple refactor: If you used MiniRx Store before, refactor to Signal Store will be pretty straight-forward: change the TypeScript imports, remove the Angular async pipes (and ugly non-null assertions (
!
)) from the template
API Preview
Let's have a closer look at the Signal Store API.
Most APIs are very similar to the original MiniRx Store. We will focus here on the changed and new APIs.
Component Store and Feature Store
FYI Feature Store and Component Store share the same API (just their internal working and their use-cases are different).
In the examples below we look at Component Store, but you can expect the same API changes for Feature Store.
All code examples can be found back in this StackBlitz.
select
select
is used to select state from your Component Store.
You can probably guess it already..., the select
method returns an Angular Signal.
Example:
import { Component, Signal } from '@angular/core';
import { createComponentStore } from '@mini-rx/signal-store';
@Component({
// ...
})
export class SelectDemoComponent {
private cs = createComponentStore({counter: 1});
doubleCounter: Signal<number> = this.cs.select(state => state.counter * 2)
}
Read the Signal like this in the template:
<pre>
doubleCount: {{doubleCounter()}},
</pre>
The select
method is exposed by Store, Feature Store and Component Store.
StackBlitz demo: SelectDemoComponent
setInitialState
There is no setInitialState
method anymore in Feature Store/Component Store for lazy state initialisation.
An initialState is now always required by Feature Store and Component Store, which is more inline with native Angular Signals.
connect
The connect
method is new! With connect
you can connect your store with external sources like Observables and Signals.
This helps to make your store the Single Source of Truth for your state.
FYI setState
does not support an Observable parameter anymore, use connect
instead.
Example:
import { Component, signal } from '@angular/core';
import { ComponentStore, createComponentStore } from '@mini-rx/signal-store';
import { timer } from 'rxjs';
interface State {
counterFromObservable: number;
counterFromSignal: number;
}
@Component({
// ...
})
export class ConnectDemoComponent {
cs: ComponentStore<State> = createComponentStore<State>({
counterFromObservable: 0,
counterFromSignal: 0,
});
constructor() {
const interval = 1000;
const observableCounter$ = timer(0, interval); // Observable
const signalCounter = signal(0); // Signal
// Connect external sources (Observables or Signals) to the Component Store
this.cs.connect({
counterFromObservable: observableCounter$, // Observable
counterFromSignal: signalCounter, // Signal
});
setInterval(() => signalCounter.update((v) => v + 1), interval);
}
}
Access the Signals in the Component template:
<!-- Access top level state properties easily from the cs.state Signal -->
<ng-container *ngIf="cs.state() as state">
<pre>
counterFromRxJS: {{ state.counterFromObservable }},
counterFromSignal: {{ state.counterFromSignal }}
</pre>
</ng-container>
StackBlitz demo: ConnectDemoComponent
rxEffect
The effect
method has been renamed to rxEffect
(to avoid confusion with the Angular Signal effect
function).
Feature Store and Component Store expose the rxEffect
method to trigger side effects like API calls.
rxEffect
returns a function which can be called later to start the side effect with an optional payload.
In the following example you can see that you can trigger the side effect with a Raw Value, an Observable and of course a Signal:
import { Component, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { createComponentStore, tapResponse } from '@mini-rx/signal-store';
import { of, Observable, map, switchMap, delay } from 'rxjs';
function apiCall(filter: string): Observable<string[]> {
// ...
}
interface State {
cities: string[];
}
@Component({
// ...
})
export class EffectDemoComponent {
cs = createComponentStore<State>({
cities: [],
});
private fetchCitiesEffect = this.cs.rxEffect<string>(
switchMap((filter) => {
return apiCall(filter).pipe(
tapResponse({
next: (cities) => this.cs.setState({ cities }),
error: console.error,
})
);
})
);
formControl = new FormControl();
private filterChangeObservable$ = this.formControl.valueChanges;
private filterChangeSignal = signal('');
constructor() {
// Observable
// Every emission of the Observable will trigger the API call
this.fetchCitiesEffect(this.filterChangeObservable$);
// Signal
// The Signals initial value will immediately trigger the API call
// Every new Signal value will trigger the API call
this.fetchCitiesEffect(this.filterChangeSignal);
}
triggerEffectWithSignal() {
// Update the Signal value
this.filterChangeSignal.set('');
setTimeout(() => {
this.filterChangeSignal.set('a');
}, 1000);
setTimeout(() => {
this.filterChangeSignal.set('c');
}, 2000);
}
triggerEffectWithRawValue() {
// Trigger the API call with a raw value
this.fetchCitiesEffect('Phi');
}
}
Component template:
<!-- Access top level state properties easily from the cs.state Signal -->
<ng-container *ngIf="cs.state() as state">
<label>Trigger Effect with RxJS Observable (FormControl.valueChanges):</label>
<input [formControl]="formControl" placeholder="Search city...">
<pre>cities: {{state.cities | json}}</pre>
<button (click)="triggerEffectWithSignal()">Trigger Effect with Signal</button><br>
<button (click)="triggerEffectWithRawValue()">Trigger Effect with Raw Value</button>
</ng-container>
StackBlitz demo: EffectDemoComponent
Component Store destruction
With Signal Store you can safely create a Component Store inside components. The Component Store will be automatically destroyed together with the component.
This is possible because the Component Store uses Angular DestroyRef
internally.
Example: Child component with a local Component Store. The child component visibility is toggled in the parent component.
@Component({
// ...
})
export class DestroyDemoChildComponent {
// Create a local Component Store
cs = createComponentStore({counter: 1});
constructor() {
// Connect a RxJS timer to the Component Store
this.cs.connect({counter: timer(0, 1000).pipe(
tap(v => console.log('timer emission:', v)) // We can see the logging WHILE the ChildComponent is visible (see the JS console)
)})
}
}
When the child component is destroyed, the Component Store will be destroyed as well.
The cleanup logic of Component Store will be executed which unsubscribes from all internal subscriptions (which includes the timer subscription).
StackBlitz demo: DestroyDemoChildComponent
Immutable Signal state
When using Angular Signals you can bypass the Signal update
or set
methods and mutate state at anytime.
This can cause unexpected behaviour and bugs.
MiniRx Signal Store comes with the ImmutableState Extension to prevent mutations (which exists also in the original MiniRx Store).
If you accidentally mutate the state, an error will be thrown in the JS console.
@Component({
// ...
})
export class ImmutableDemoComponent {
private signalState = signal({counter: 1});
counterFromSignal = computed(() => this.signalState().counter);
cs = createComponentStore({counter: 1}, {
extensions: [new ImmutableStateExtension() // FYI you could add extensions globally with `provideComponentStoreConfig` in main.ts
]});
counterFromComponentStore = this.cs.select(state => state.counter);
// SIGNAL
// valid state update
incrementSignalCounter() {
this.signalState.update(state => ({...state, counter: state.counter + 1}))
}
// Signal Mutations
// no error, you are entering danger zone, without knowing it
mutateSignalA() {
this.signalState().counter = 666;
}
mutateSignalB() {
this.signalState.update(state => {
state.counter = 666;
return state;
})
}
// COMPONENT STORE
// valid state update
incrementComponentStoreCounter() {
this.cs.setState(state => ({counter: state.counter + 1}))
}
// Component Store Signal Mutations
// As expected, mutating state will throw an error
mutateComponentStoreSignalA() {
this.cs.state().counter = 666;
}
mutateComponentStoreSignalB() {
this.cs.setState(state => {
state.counter = 666;
return state;
})
}
}
StackBlitz demo: ImmutableDemoComponent
Memoized Signal Selectors
createSelector
, createFeatureStateSelector
and createComponentStateSelector
return a SignalSelector function.
Signal Selector functions take a Signal and return a Signal.
You can pass Signal Selectors to the select
method of Store, Feature Store and Component Store.
Signal Selectors are memoized for fewer computations of the projector function.
Fun fact: Angular Signal computed
is used to implement Signal Selectors.
Example: Selecting state from the Redux Store
import { Component, inject, Signal } from "@angular/core";
import { createFeatureStateSelector, createSelector, Store } from "@mini-rx/signal-store";
import { Todo, TodosState } from "./todo-state";
// Memoized SignalSelectors
const getFeature = createFeatureStateSelector<TodosState>('todos');
const getTodos = createSelector(getFeature, state => state.todos);
const getTodosDone = createSelector(getTodos, todos => todos.filter(item => item.isDone))
const getTodosNotDone = createSelector(getTodos, todos => todos.filter(item => !item.isDone))
@Component({
// ...
})
export class MemoizedSignalSelectorsDemoComponent {
private store = inject(Store); // Store is provided in the main.js file
todosDone: Signal<Todo[]> = this.store.select(getTodosDone);
todosNotDone: Signal<Todo[]> = this.store.select(getTodosNotDone);
}
Access the Signals in the Component template:
<pre>DONE: {{ todosDone() | json }}</pre>
<pre>NOT DONE: {{ todosNotDone() | json }}</pre>
StackBlitz demo: MemoizedSignalSelectorsDemoComponent
Store (Redux)
Let's have a quick look at the API changes of the Store (Redux) API...
select
select
is used to select state from your store. The select
method returns an Angular Signal.
We can look again at the memoized selectors example to see select
in action:
import { Component, inject, Signal } from "@angular/core";
import { createFeatureStateSelector, createSelector, Store } from "@mini-rx/signal-store";
import { Todo, TodosState } from "./todo-state";
@Component({
// ...
})
export class MemoizedSignalSelectorsDemoComponent {
private store = inject(Store); // Store is provided in the main.js file
todosDone: Signal<Todo[]> = this.store.select(getTodosDone);
todosNotDone: Signal<Todo[]> = this.store.select(getTodosNotDone);
}
createRxEffect
The (Redux) Store effects API is pretty much unchanged. Just createEffect
has been renamed to createRxEffect
. The new name clearly indicates that the method is used in relation to RxJS Observables.
Small example from the Signal Store RFC:
import {
Actions,
createRxEffect,
mapResponse,
} from '@mini-rx/signal-store';
import { ofType } from 'ts-action-operators';
@Injectable()
export class ProductsEffects {
constructor(private productService: ProductsApiService, private actions$: Actions) {}
loadProducts$ = createRxEffect(
this.actions$.pipe(
ofType(load),
mergeMap(() =>
this.productService.getProducts().pipe(
mapResponse(
(products) => loadSuccess(products),
(error) => loadFail(error)
)
)
)
)
);
}
Standalone APIs
MiniRx Signal Store got modern Angular standalone APIs.
Here is a quick overview:
-
provideStore
: Set up the Redux Store with reducers, metaReducers and extensions -
provideFeature
: Add a feature state with a reducer (via the route config) -
provideEffects
: Register effects (via the route config) -
provideComponentStoreConfig
: Configure all Component Stores with the same config
FYI In module-based Apps you can still use the classic API: StoreModule.forRoot()
, StoreModule.forFeature()
, EffectsModule.register()
and ComponentStoreModule.forRoot()
.
Feedback
We hope that you like the upcoming Signal Store!
If you see things which could be better or different, please let us know and leave a comment.
You can also contribute to Signal Store by commenting on the RFC or Pull Request on GitHub.
Thanks
Special thanks for reviewing this blog post:
Top comments (2)
Thank you, Another state managment. So compate to ngrx, rx-angular and state-adapt, Can you please tell me what are the benefits of using mini-rx signal store?
So pepole like me can pick the right thing.
Hey @timsar2 ,
I made once a comparison: MiniRx Feature Store vs. NgRx Component Store vs. Akita.
Most things should still be valid.
The greatest things about MiniRx Store and MiniRx Signal Store:
createComponentStore
).MiniRx Signal Store will have a even better integration with Angular and is tailored to Signals. It also streamlines the usage of Observables and Signals which will co-exist in Modern Angular Applications.