MiniRx Signal Store for Angular - API Preview

Florian Spier - Nov 17 '23 - - Dev Community

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .