DEV Community

Cover image for MiniRx Signal Store for Angular - API Preview
Florian Spier for This is Angular

Posted on • Edited on

MiniRx Signal Store for Angular - API Preview

MiniRx Signal Store is in the making...

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 and rxEffect 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)
}
Enter fullscreen mode Exit fullscreen mode

Read the Signal like this in the template:

<pre>
  doubleCount: {{doubleCounter()}},
</pre>  
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
    )})
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Access the Signals in the Component template:

<pre>DONE: {{ todosDone() | json }}</pre>
<pre>NOT DONE: {{ todosNotDone() | json }}</pre>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
          )
        )
      )
    )
  );
} 
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
timsar2 profile image
timsar2 • Edited

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.

Collapse
 
spierala profile image
Florian Spier • Edited

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:

  • Flexibility:
    • Global state management:
      • Redux Store
      • Feature Store (Redux under the hood, but use it without the "Redux boilerplate")
    • Component Store for fully local state management
    • You get it all in one library
  • Lightweight: even if you would use all 3 Store APIs
  • Redux DevTools for the Redux Store and Feature Store
  • High level of integration for Store / Feature Store / Component Store: e.g. use Memoized selectors in all 3 Store APIs, have something for undo everywhere, something for effects everywhere, Feature Store and Component Store share even the same API. Extensions work everywhere (just the Redux DevTools extension is tailored to Store and Feature Store).
  • Easy switch from local state management to global state management: Refactor Component Store to Feature Store is changing two lines of code.
  • Easy Setup: No setup is needed for using Component Store and Feature Store.
  • Small and focused API: In MiniRx there are not 4 ways to do the same thing or 5 function overloads for the same function.
  • The original MiniRx Store is even framework-agnostic, but has also an Angular integration (mini-rx-store-ng).
  • MiniRx is more OOP style, but has also functional creation methods (e.g. createComponentStore).
  • Last but not least: Good documentation: mini-rx.io/

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.