ℹ️ Version 2: Currently I am working on version 2 of mini-rx-store. Please let me know if you have ideas for features that you wish to see in mini-rx-store@2. See discussion here: mini-rx#19
MiniRx Store provides Reactive State Management for Javascript Applications inspired by Redux.
- Minimal configuration and setup
- "Redux" API:
- Actions
- Reducers
- Memoized Selectors
- Effects
- Support for ts-action: Create and consume actions with as little boilerplate as possible
- "Feature" API: Update state without actions and reducers:
setState()update the feature stateselect()read feature statecreateEffect()run side effects like API calls and update feature state
- Support for Redux Dev Tools
- Framework agnostic: Works with any front-end project built with JavaScript or TypeScript (Angular, React, Vue, or anything else)
MiniRx is powered by RxJS. It uses RxJS Observables to notify subscribers about state changes.
MiniRx uses the Redux Pattern to make state management easy and predictable.
The Redux Pattern is based on this 3 key principles:
- Single source of truth (the Store)
- State is read-only and is only changed by dispatching actions
- Changes are made using pure functions called reducers
npm i mini-rx-store
Make hard things simple
A feature holds a piece of state which belongs to a specific feature in your application (e.g. 'products', 'users'). The feature states together form the app state (Single source of truth).
Usually you would create a new feature inside long living Modules/Services:
import { Store } from 'mini-rx-store';
import { ProductState, reducer } from './state/product.reducer';
Store.feature<ProductState>('products', reducer);The code above creates a new feature state for products.
Store.feature receives the feature name, and a reducer function.
Reducers specify how the feature state changes in response to actions sent to the store. A reducer function typically looks like this:
const initialState: ProductState = {
showProductCode: true,
products: [],
};
export function reducer(state: ProductState = initialState, action: ProductActions): ProductState {
switch (action.type) {
case ProductActionTypes.ToggleProductCode:
return {
...state,
showProductCode: action.payload
};
default:
return state;
}
}import { Action } from 'mini-rx-store';
export enum ProductActionTypes {
CreateProduct = '[Product] Create Product',
}
export class CreateProduct implements Action {
readonly type = ProductActionTypes.CreateProduct;
constructor(public payload: Product) { }
}Dispatch an action to update state:
import { Store } from 'mini-rx-store';
import { CreateProduct } from 'product.actions';
Store.dispatch(new CreateProduct(product));After the action has been dispatched the state will be updated accordingly (as defined in the reducer function).
Effects handle code that triggers side effects like API calls:
- An Effect listens for a specific action
- That action triggers the actual side effect
- The Effect needs to return a new action as soon as the side effect finished
import { Action, actions$, ofType } from 'mini-rx-store';
import { mergeMap, map, catchError } from 'rxjs/operators';
import { LoadFail, LoadSuccess, ProductActionTypes } from './product.actions';
import { ProductService } from '../product.service';
constructor(private productService: ProductService) {
Store.createEffect(
actions$.pipe(
ofType(ProductActionTypes.Load),
mergeMap(() =>
this.productService.getProducts().pipe(
map(products => (new LoadSuccess(products))),
catchError(err => of(new LoadFail(err)))
)
)
)
);
}The code above creates an Effect. As soon as the Load action has been dispatched the API call (this.productService.getProducts()) will be executed. Depending on the result of the API call a new action will be dispatched:
LoadSuccess or LoadFail.
Selectors are used to select and combine state.
import { createFeatureSelector, createSelector } from 'mini-rx-store';
import { ProductState } from './product.reducer';
const getProductFeatureState = createFeatureSelector<ProductState>('products');
export const getProducts = createSelector(
getProductFeatureState,
state => state.products
);createSelector creates a memoized selector. This improves performance especially if your selectors perform expensive computation.
If a selector is called with the same arguments again, it will just return the previously calculated result.
import { Store } from 'mini-rx-store';
import { getProducts } from '../../state';
this.products$ = Store.select(getProducts);Store.select runs the selector against the app state and returns an Observable which will emit as soon as the products data changes.
MiniRx supports writing and consuming actions with ts-action to reduce boilerplate code.
There are also ts-action-operators to consume actions in Effects.
Install the packages using npm:
npm install ts-action ts-action-operators
import { action, payload } from 'ts-action';
export const createProduct = action('[Product] Create Product', payload<Product>());import { Store } from 'mini-rx-store';
import { createProduct } from './../../state/product.actions';
Store.dispatch(createProduct(product));import { on, reducer } from 'ts-action';
export const productReducer = reducer(
initialState,
on(toggleProductCode, (state, {payload}) => ({...state, showProductCode: payload}))
);Consume actions in Effects
import { Action, actions$, Store } from 'mini-rx-store';
import { ofType, toPayload } from 'ts-action-operators';
updateProduct$: Observable<Action> = actions$.pipe(
ofType(updateProduct),
toPayload(),
mergeMap((product) => {
return this.productService.updateProduct(product).pipe(
map(updatedProduct => (updateProductSuccess(updatedProduct))),
catchError(err => of(updateProductFail(err)))
);
})
);Make simple things simple
If a feature in your application requires only simple state management, then you can fall back to a simplified API:
With the Feature API you can update state without writing actions and reducers.
To create a Feature, you need to extend MiniRx's Feature class, passing the feature name as well as its initial state.
interface UserState {
currentUser: User;
favProductIds: string[];
}
const initialState: UserState = {
currentUser: undefined,
favProductIds: []
};
export class UserStateService extends Feature<UserState>{
constructor() {
super('users', initialState);
}
}select(mapFn: (state: S) => any): Observable<any>
Example:
currentUser$: Observable<User> = this.select(state => state.currentUser);select takes a callback function which gives you access to the current feature state (see the state parameter).
Inside of that function you can pick a certain piece of state.
select returns an Observable which will emit as soon as the selected data changes.
You can use memoized selectors also with the Feature API... You only have to omit the feature name when using createFeatureSelector.
This is because the Feature API is operating on a specific feature state already (the corresponding feature name has been provided in the constructor).
const getProductFeatureState = createFeatureSelector<ProductState>(); // Omit the feature name!
const getProducts = createSelector(
getProductFeatureState,
state => state.products
);
// Inside the Feature state service
export class ProductStateService extends Feature<ProductState>{
this.products$ = this.select(getProducts);
constructor(private productService: ProductService) {
super('products', initialState); // Feature name 'products' is provided here already...
}
}setState(state: Partial<S>, name?: string): void
Example:
updateUser(user: User) {
this.setState({currentUser: user});
}setState sets the new state of the feature.
// Update state based on current state
addFavorite(productId) {
this.setState({
favProductIds: [...this.state.favProductIds, productId]
});
}Do you want to calculate the new state based on the current state?
You can use this.state which holds the current state snapshot.
For better logging in the JS Console / Redux Dev Tools you can provide an optional name to the setState function:
this.setState({currentUser: user} 'updateUser');createEffect<PayLoadType = any>(effectFn: (payload: Observable<PayLoadType>) => Observable<Partial<S>>, effectName?: string): (payload?: PayLoadType) => void
createEffect offers a simple way to trigger side effects (e.g. API calls)
and update feature state straight away.
Example:
import { catchError, map, mergeMap } from 'rxjs/operators';
import { of } from 'rxjs';
createProduct = this.createEffect<Product>(
mergeMap((product) =>
this.productService.createProduct(product).pipe(
map((newProduct) => ({
products: [...this.state.products, newProduct],
currentProductId: newProduct.id,
error: '',
})),
catchError((error) =>
of({
error,
})
)
)
),
'create'
);
// Run the effect
createProduct(product);The code above creates an Effect for creating a product.
The API call this.productService.createProduct is the side effect which needs to be performed.
createEffect returns a function which can be called later to start the Effect with an optional payload (see createProduct(product)).
createEffect takes 2 arguments:
-
effectFn: (payload: Observable<PayLoadType>) => Observable<Partial<S>>: TheeffectFnis a function that takes an Observable as its input and returns another Observable. That is exactly the definition of RxJS operators :) Therefore we can use RxJS (flattening) operators aseffectFncallback to control how the actual side effect is triggered. (e.g.mergeMap,switchMap,concatMap,exhaustMap).The input of
effectFnis an Observable which emits the payload argument of the function which starts the Effect (e.g.productis the payload when callingcreateProduct(product)).Finally
effectFnhas to return an Observable with the new feature state. -
effectName: string: Optional name which needs to be unique for each effect. That name will show up in the logging (Redux Dev Tools / JS console).
FYI: See how RxJS flattening operators are triggering api calls:
FYI: How the Feature API works
Also the Feature API makes use of Redux:
Each feature is registered in the Store (Single source of truth) and is part of the global application state.
Behind the scenes Feature is creating a default reducer, and a default action in order to update the feature state.
When you use setState() or when the feature´s effect completed, then MiniRx dispatches the default action,
and the default reducer will update the feature state accordingly.
import { Store } from 'mini-rx-store';
Store.settings({enableLogging: true});The code above sets the global Store settings.
enableLogging is currently the only available setting.
Typically, you would set the settings when bootstrapping the app and before the Store is used.
MiniRx has basic support for the Redux Dev Tools (you can time travel and inspect the current state). You need to install the Browser Plugin to make it work.
Currently, these options are available to configure the DevTools:
name: the instance name to be shown on the DevTools monitor page.maxAge: maximum allowed actions to be stored in the history tree. The oldest actions are removed once maxAge is reached. It's critical for performance. Default is 50.latency: if more than one action is dispatched in the indicated interval, all new actions will be collected and sent at once. Default is 500 ms.
npm i mini-rx-ng-devtools
import { NgReduxDevtoolsModule } from 'mini-rx-ng-devtools';
@NgModule({
imports: [
NgReduxDevtoolsModule.instrument({
name: 'MiniRx Showcase',
maxAge: 25,
latency: 1000
})
]
...
})
export class AppModule {}import { Store, ReduxDevtoolsExtension } from 'mini-rx-store';
Store.addExtension(new ReduxDevtoolsExtension({
name: 'MiniRx Showcase',
maxAge: 25,
latency: 1000
}));This Repo contains also two Angular showcase projects.
Run npm i
See the MiniRx "Redux" API in action:
Run ng serve mini-rx-store-showcase-redux --open
See the MiniRx "Feature" API in action:
Run ng serve mini-rx-store-showcase --open
The showcases are based on the NgRx example from Deborah Kurata: https://github.com/DeborahK/Angular-NgRx-GettingStarted/tree/master/APM-Demo5
These projects, articles and courses helped and inspired me to create MiniRx:
- NgRx
- Akita
- Observable Store
- RxJS Observable Store
- Basic State Managment with an Observable Service
- Redux From Scratch With Angular and RxJS
- How I wrote NgRx Store in 63 lines of code
- NGRX VS. NGXS VS. AKITA VS. RXJS: FIGHT!
- Pluralsight: Angular NgRx: Getting Started
- Pluralsight: RxJS in Angular: Reactive Development
- Pluralsight: RxJS: Getting Started
MIT
If you like this, follow @spierala on twitter.