StateX is a state management library for modern web applications with unidirectional data flow and immutable uni-state. StateX is a predictable state container just like REDUX. It helps you implement a unidirectional data flow (Flux architecture) in an easy and elegant way without much boilerplate code. The main objective is to provide an implementation that has minimal touch points, while providing all the benefits of Redux. StateX uses rxjs
library at its heart, hence promises efficient data flow. StateX is inspired by refluxjs and redux.
To enable seamless integration, StateX has specific APIs for Angular (2 or above) and React.
Note: StateX was originally written for angular - angular-reflux and later modified for react - react-reflux. Both of these packages are now migrated to StateX
- StateX
- Architecture
- Install
- Usage
- Examples
- 5 Simple Steps
- Organizing Stores
- Reducer Functions & Async Tasks
- Initialize State & Enable HotLoad
- Immutable Application State
- Migrating
- About
Flux is an architecture for unidirectional data flow. By forcing the data to flow in a single direction, Flux makes it easy to reason how data-changes will affect the application depending on what actions have been issued. The components themselves may only update application-wide data by executing an action to avoid double maintenance nightmares.
-
STATE - contains application wide data. Technically this is a single immutable JavaScript object containing every data that an application needs.
-
STORES - contain business logic - how an action should transform the application wide data represented by
STATE
-
VIEWS - Views must react to the change in
STATE
. So an event is triggered whenSTATE
changes, whichVIEWS
can consume to update itself with the new data. -
ACTIONS - are dispatched whenever a view needs to change application state. The actions contain payload to help store complete the updates.
npm install statex --save
StateX works with any modern JavaScript framework, however there are minor differences to how it is implemented for each framework.
This guide includes instructions to integrate StateX with the following combinations of frameworks and features
Framework | Language | Decorator | |
---|---|---|---|
Angular | TypeScript | YES | (Recommended) |
Angular | TypeScript | NO | |
React | TypeScript | YES | (Recommended) |
React | ES6 | YES | |
React | ES6 | NO |
Use @angular/cli to get started with Angular
Use react-ts to get started with React & TypeScript
Use create-react-app to get started with React & ES6
or use one of the following examples
- Angular with TypeScript & Decorators - AOT Compatible
- Angular with TypeScript & without Decorators - AOT Compatible
- React with TypeScript & Decorators
- React with ES6 & Decorators
- React with ES6 & without Decorators
To get the best out of TypeScript, declare an interface that defines the structure of the application-state. This is optional if you don't want to use TypeScript.
export interface Todo {
id?: string
title?: string
completed?: boolean
}
export type Filter = 'ALL' | 'ACTIVE' | 'COMPLETED'
export interface AppState {
todos?: Todo[]
filter?: Filter
}
Define actions as classes with the necessary arguments passed on to the constructor. This way we will benefit from the type checking; never again we will miss-spell an action, miss a required parameter or pass a wrong parameter. Remember to extend the action from Action
class. This makes your action listenable and dispatch-able.
import { Action } from 'statex';
export class AddTodoAction extends Action {
constructor(public todo: Todo) {
super()
}
}
import { Action } from 'statex';
export class AddTodoAction extends Action {
constructor(todo) {
super()
this.todo = todo
}
}
Stores are the central part of a Flux architecture. While most of the logics for a store are same, some of the minor details vary from framework to framework.
Use @action
decorator to bind a reducer function with an Action. The second parameter to the reducer function (addTodo
) is an action (of type AddTodoAction
); @action
uses this information to bind the correct action. Also remember to extend this class from Store
.
import { Injectable } from '@angular/core'
import { action, Store } from 'statex/angular'
@Injectable()
export class TodoStore extends Store {
@action()
addTodo(state: AppState, payload: AddTodoAction): AppState {
return { todos: state.todos.concat([payload.todo]) }
}
}
import { Injectable } from '@angular/core'
@Injectable()
export class TodoStore {
constructor() {
new AddTodoAction(undefined).subscribe(this.addTodo, this)
}
addTodo(state: AppState, payload: AddTodoAction): AppState {
return { todos: state.todos.concat([payload.todo]) }
}
}
This store will be instantiated by Angular's dependency injection.
Use @action
decorator to bind a reducer function with an Action. The second parameter to the reducer function (addTodo
) is an action (of type AddTodoAction
); @action
uses this information to bind the correct action.
import { AppState } from '../state';
import { AddTodoAction } from '../action';
import { action, store } from 'statex/react';
@store()
export class TodoStore {
@action()
addTodo(state: AppState, payload: AddTodoAction): AppState {
return { todos: state.todos.concat([payload.todo]) }
}
}
Stores must bind each action with the reducer function at the startup and also must have a singleton instance. Both of these are taken care by @store
decorator.
import { AddTodoAction } from '../action';
import { action, store } from 'statex/react';
@store()
export class TodoStore {
@action(AddTodoAction)
addTodo(state, payload) {
return { todos: state.todos.concat([payload.todo]) }
}
}
@action
takes an optional parameter - the action class. Always remember to add @store()
to the class.
import { AddTodoAction } from '../action';
export class TodoStore {
constructor() {
new AddTodoAction().subscribe(this.addTodo, this)
}
addTodo(state, payload) {
return { todos: state.todos.concat([payload.todo]) }
}
}
new TodoStore()
Remember to instantiate the store at the end.
No singleton dispatcher! Instead StateX lets every action act as dispatcher by itself. One less dependency to define, inject and maintain.
new AddTodoAction({ id: 'sd2wde', title: 'Sample task' }).dispatch();
Use @data
decorator and a selector function (parameter to the decorator) to get updates from application state. The property gets updated only when the value returned by the selector function changes from previous state to the current state. Additionally, just like a map function, you could map the data to another value as you choose.
We may, at times need to derive additional properties from the data, sometimes using complex calculations. Therefore @data
can be used with functions as well.
See framework specific implementation.
While creating an Angular component, remember to extend it from DataObserver
. It is essential to instruct Angular Compiler to keep ngOnInit
and ngOnDestroy
life cycle events, which can only be achieved by implementing OnInit
and OnDestroy
interfaces. DataObserver
is responsible for subscribing to state stream when the component is created and for unsubscribing when the component is destroyed. The selector function must also be kept as external functions accessible to outside modules, therefore add export
to every selector function.
import { data, DataObserver } from 'statex/angular';
export const selectState = (state: AppState) => state
export const selectTodos = (state: AppState) => state.todos
export const computeHasTodos = (state: AppState) => state.todos && state.todos.length > 0
@Component({
....
})
export class TodoListComponent extends DataObserver {
@data(selectTodos) // mapping a direct value from state
todos: Todo[];
@data(computeHasTodos) // mapping a different value from state
hasTodos: boolean;
@data(selectState) // works with functions to allow complex calculations
todosDidChange(state: AppState) {
// you logic here
}
}
StateX can also be used without decorators as shown below, however this is not a recommended way. At most care must be taken to unsubscribe all the events on destroy.
import { State } from 'statex'
import { Subscription } from 'rxjs/Subscription'
export const selectTodos = (state: AppState) => state.todos
export const selectFilter = (state: AppState) => state.filter
@Component({
...
})
export class AppComponent implements OnInit, OnDestroy {
todos: Todo[]
filter: Filter
subscriptions: Subscription[] = []
ngOnInit() {
this.subscriptions.push(
State.select(selectTodos).subscribe(todos => this.todos = todos)
)
this.subscriptions.push(
State.select(selectFilter).subscribe(filter => this.filter = filter)
)
}
ngOnDestroy() {
this.subscriptions.forEach(subscription => subscription.unsubscribe())
this.subscriptions = []
}
}
Create Props
class, add properties decorated with @data
, and finally inject the Props
to the container using @inject
decorator.
import * as React from 'react'
import { data, inject } from 'statex/react'
class Props {
@data((state: AppState) => state.todos)
todos: Todo[]
@data((state: AppState) => state.todos && state.todos.length > 0)
hasTodos: boolean
}
interface State { }
@inject(Props)
export class TodoListComponent extends React.Component<Props, State> {
render() {
const todos = this.props.todos.map(
todo => <li key={todo.id}>{todo.text}</li>
)
return <div> { this.props.hasTodos && <ul> {todos} </ul> } </div>
}
}
Create Props
class, add properties decorated with @data
, and finally inject the Props
to the container using @inject
decorator.
import * as React from 'react'
import { inject } from 'statex/react'
@inject({
todos: state => state.todos,
hasTodos: state => state.todos && state.todos.length > 0
})
export class TodoListComponent extends React.Component {
render() {
const todos = this.props.todos.map(
todo => <li key={todo.id}>{todo.text}</li>
)
return <div> { this.props.hasTodos && <ul> {todos} </ul> } </div>
}
}
import React from 'react'
import { State } from 'statex';
export class TodoListComponent extends React.Component {
subscriptions = [];
constructor(props) {
super(props)
this.state = {
todos: [],
hasTodos: false
}
}
componentDidMount() {
this.subscriptions.push(
State.select(selectTodos).subscribe(todos => this.setState({ todos }))
)
this.subscriptions.push(
State.select(computeHasTodos).subscribe(hasTodos => this.setState({ hasTodos }))
)
}
componentWillUnmount() {
this.subscriptions.forEach(subscription => subscription.unsubscribe())
this.subscriptions = []
}
render() {
const todos = this.state.todos.map(
todo => <li key={todo.id}>{todo.text}</li>
)
return <div> { this.state.hasTodos && <ul> {todos} </ul> } </div>
}
}
- Create
STORES
array and a classStores
(again injectable) to maintain stores.
import { Injectable } from '@angular/core';
import { TodoStore } from './todo.store';
@Injectable()
export class Stores {
constructor( private todoStore: TodoStore) { }
}
export const STORES = [
Stores,
TodoStore
];
When you create a new store remember to inject to the
Stores
's constructor and add it to theSTORES
array.
- Add
STORES
to theproviders
inapp.module.ts
.
import { STORES } from './store/todo.store';
@NgModule({
providers: [
...STORES
],
bootstrap: [AppComponent]
})
export class AppModule { }
- And finally, inject
Stores
into your root component (app.component.ts
)
@Component({
....
})
export class AppComponent {
constructor(private stores: Stores) { }
}
Create index.ts
in stores
folder and import all stores. You must do this every store you create.
import './todo-store'
Import stores into application (app.tsx
), so that application is aware of the stores. This has to be done once at the beginning of the setup. Next time you create a new store, it must only be added to store/index.ts
import './stores'
...
export class AppComponent extends React.Component<{}, {}> {
...
}
Reducer functions can return either of the following
- A portion of the application state as plain object
@action()
add(state: AppState, payload: AddTodoAction): AppState {
return {
todos: (state.todos || []).concat(payload.todo)
}
}
- A port of the application state using async and await (RECOMMENDED)
@action()
async add(state: AppState, payload: AddTodoAction): Promise<AppState> {
const response = await asyncTask();
return (currentState: AppState) => ({
todos: (currentState.todos || []).concat(response.todo)
})
}
Please note: the state might change by the time the
asyncTask()
is completed. So it is recommended to return a function that will receive the current state as shown above. Do all calculations based oncurrentState
instead ofstate
- A portion of the application state wrapped in Promise, if it needs to perform an async task.
@action()
add(state: AppState, payload: AddTodoAction): Promise<AppState> {
return new Promise((resolve, reject) => {
asyncTask().then(() => {
resolve((currentState: AppState) => ({
todos: (currentState.todos || []).concat(payload.todo)
}))
})
})
}
- A portion of the application state wrapped in Observables, if the application state needs update multiple times over a period of time, all when handling an action. For example, you have to show loader before starting the process, and hide loader after you have done processing, you may use this.
import { Observable } from 'rxjs/Observable'
import { Observer } from 'rxjs/Observer'
@action()
add(state: AppState, payload: AddTodoAction): Observable<AppState> {
return Observable.create((observer: Observer<AppState>) => {
observer.next({ showLoader: true })
asyncTask().then(() => {
observer.next((currentState: AppState) => ({
todos: (currentState.todos || []).concat(payload.todo),
showLoader: false
}))
observer.complete()
})
})
}
You can initialize the app state using the following code.
import { initialize } from 'statex'
initialize(INITIAL_STATE, {
// set hot load to true to save and resume state between reloads
hotLoad: process.env.NODE_ENV !== 'production',
// show reducer errors; turn this off for production builds for performance reasons
showError: process.env.NODE_ENV !== 'production',
// (electron only option) see electron section for details
cache: '.my-app-cache.json'
// set this to uniquely identify your app in a common domain; in effect only if "cache" is not defined"
domain: 'my-app'
})
If you set hotLoad
to true, every change to the state is preserved in localStorage and re-initialized upon refresh. If a state exists in localStorage INITIAL_STATE
will be ignored. This is useful for development builds because developers can return to the same screen after every refresh. Remember that the screens must react to state (reactive UI) in-order to achieve this. domain
is an optional string to uniquely identify your application. showError
, if set to true, displays console errors when the actions are rejected.
...
import { INITIAL_STATE } from './../state'
import { environment } from '../environments/environment'
import { initialize } from 'statex/angular'
initialize(INITIAL_STATE, {
hotLoad: !environment.production,
showErro: !environment.production,
domain: 'my-app'
})
@NgModule({
....
bootstrap: [AppComponent]
})
export class AppModule { }
...
import { INITIAL_STATE } from './../state'
import { initialize } from 'statex/react'
initialize(INITIAL_STATE, {
hotLoad: process.env.NODE_ENV !== 'production',
showError: process.env.NODE_ENV !== 'production',
domain: 'my-app'
})
import { AppComponent } from './app'
ReactDOM.render(<AppComponent />, document.getElementById('root'))
If you are building an electron app (using Angular or React), you can overcome the size limitation of localStorage using cache
option. Set this property to a valid file name so that Statex will save the state in a local file instead of local storage. Remember to import initialize
function from statex/electron
.
...
import * as os from 'os'
import { INITIAL_STATE } from './../state'
import { initialize } from 'statex/electron'
initialize(INITIAL_STATE, {
hotLoad: process.env.NODE_ENV !== 'production',
showError: process.env.NODE_ENV !== 'production',
cache: path.resolve(os.tmpdir(), 'my-app-cache.json')
})
To take best use of React's and Angular's change detection strategies we need to ensure that the state is indeed immutable. This module uses seamless-immutable for immutability.
Since application state is immutable, the reducer functions will not be able to update state directly; any attempt to update the state will result in error. Therefore a reducer function should either return a portion of the state that needs change (recommended) or a new application state wrapped in ReplaceableState
, instead.
@action()
selectTodo(state: AppState, payload: SelectTodoAction): AppState {
// merge with the existing state
return {
selectedTodo: payload.todo
}
}
@action()
resetTodos(state: AppState, payload: ResetTodosAction): AppState {
// replace the current state completely with the new one
return new ReplaceableState({
todos: [],
selectedTodo: undefined
})
}
- Replace
angular-reflux
package withstatex
npm uninstall react-reflux --save
npm install statex --save
- Replace imports from
angular-reflux
tostatex/angular
// from
import { data, DataObserver } from 'angular-reflux'
// to
import { data, DataObserver } from 'statex/angular'
- Change
@BindAction()
to@action()
// from
@BindAction()
addTodo(state: AppState, action: AddTodoAction): AppState {
...
}
// to
@action()
addTodo(state: AppState, action: AddTodoAction): AppState {
...
}
- Change
@BindData()
to@data()
// from
@Component({...})
export class AppComponent extends DataObserver {
@BindData(selectTodos)
todos: Todo[]
}
// to
@Component({...})
export class AppComponent extends DataObserver {
@data(selectTodos)
todos: Todo[]
}
To migrate to StateX replace the package react-reflux
with statex
.
npm uninstall react-reflux --save
npm install statex --save
And change every import statement from react-reflux
to statex/react
. That's all
// from
import { data, inject } from 'react-reflux'
// to
import { data, inject } from 'statex/react'
Hope StateX is helpful to you. Please make sure to checkout my other projects and articles. Enjoy coding!
Contributions are very welcome! Just send a pull request. Feel free to contact me or checkout my GitHub page.
Rinto Jose (rintoj)
Follow me: GitHub | Facebook | Twitter | Google+ | Youtube
The MIT License (MIT)
Copyright (c) 2017 Rinto Jose (rintoj)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.