├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public └── index.html └── src ├── index.js └── reducers ├── index.js └── index.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # production 7 | build 8 | 9 | # misc 10 | .DS_Store 11 | npm-debug.log 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux Statechart 2 | 3 | To use this please check out my article https://medium.freecodecamp.org/how-to-model-the-behavior-of-redux-apps-using-statecharts-5e342aad8f66 and the xstate project: https://github.com/davidkpiano/xstate 4 | 5 | * [Install](#install) 6 | * [Create your statechart JSON](#create-your-statechart-json) 7 | * [Install xstate](#install-xstate) 8 | * [The Redux middleware](#the-redux-middleware) 9 | * [Reducer](#reducer) 10 | * [Finally put everything together](#finally-put-everything-together) 11 | * [Best practices](#best-practices) 12 | * [Folder structure](#folder-structure) 13 | 14 | ## Install 15 | 16 | ### Create your statechart JSON 17 | 18 | ```js 19 | const statechart = { 20 | initial: 'Init', 21 | states: { 22 | Init: { 23 | on: { CLICKED_PLUS: 'Init.Increment' }, 24 | states: { 25 | Increment: { 26 | onEntry: INCREMENT 27 | } 28 | } 29 | } 30 | } 31 | } 32 | ``` 33 | 34 | ### Install xstate 35 | 36 | Install xstate `yarn add xstate` and create the machine object 37 | 38 | ```js 39 | import { Machine } from 'xstate' // yarn add xstate 40 | 41 | const machine = Machine(statechart) 42 | ``` 43 | 44 | ### The Redux middleware 45 | 46 | ```js 47 | const UPDATE = '@@statechart/UPDATE' 48 | 49 | export const statechartMiddleware = store => next => (action) => { 50 | const state = store.getState() 51 | const currentStatechart = state.statechart // this has to match the location where you mount your reducer 52 | 53 | const nextMachine = machine.transition(currentStatechart, action) 54 | 55 | const result = next(action) 56 | 57 | // run actions 58 | nextMachine.actions.forEach(actionType => 59 | store.dispatch({ type: actionType, payload: action.payload })) 60 | 61 | // save current statechart 62 | if (nextMachine && action.type !== UPDATE) { 63 | if (nextMachine.history !== undefined) { 64 | // if there's a history, it means a transition happened 65 | store.dispatch({ type: UPDATE, payload: nextMachine.value }) 66 | } 67 | } 68 | 69 | return result 70 | } 71 | ``` 72 | 73 | ### Reducer 74 | 75 | ```js 76 | export function statechartReducer(state = machine.initialState, action) { 77 | if (action.type === UPDATE) { 78 | return action.payload 79 | } 80 | return state 81 | } 82 | ``` 83 | 84 | ### Finally put everything together 85 | 86 | ```js 87 | const rootReducer = combineReducers({ 88 | statechart: statechartReducer 89 | }) 90 | 91 | const store = createStore( 92 | rootReducer, 93 | applyMiddleware( 94 | statechartMiddleware, 95 | ), 96 | ) 97 | 98 | // make sure your initial state actions are called 99 | machine.initialState.actions.forEach(actionType => 100 | store.dispatch({ type: actionType })) 101 | ``` 102 | 103 | ## Best practices 104 | 105 | ### Folder structure 106 | 107 | It makes sense to separate your states into specific folders, and have each folder contain the reducers, epics, constants, selectors and containers pertaining that specific state. Turns out statechart not only are a great tool to model behavior, but also to organize our apps in a filesystem! Since a statechart is hierarchical, this follows perfectly the filesystem structure. 108 | 109 | For instance, imagine this statechart example: 110 | 111 | ```js 112 | { 113 | initial: 'Init', 114 | states: { 115 | Init: { 116 | on: { 117 | FETCH_DATA_CLICKED: 'FetchingData', 118 | }, 119 | initial: 'NoData', 120 | states: { 121 | ShowData: {}, 122 | Error: {}, 123 | NoData: {} 124 | } 125 | }, 126 | FetchingData: { 127 | on: { 128 | FETCH_DATA_SUCCESS: 'Init.ShowData', 129 | FETCH_DATA_FAILURE: 'Init.Error', 130 | CLICKED_CANCEL: 'Init.NoData', 131 | }, 132 | onEntry: 'FETCH_DATA_REQUEST', 133 | onExit: 'FETCH_DATA_CANCEL', 134 | }, 135 | } 136 | } 137 | ``` 138 | 139 | One can imagine separating this JSON into several files: 140 | 141 | ``` 142 | ├── FetchingData.js 143 | ├── Init 144 | │   ├── Error.js 145 | │   ├── NoData.js 146 | │   ├── ShowData.js 147 | │   └── index.js 148 | └── index.js 149 | ``` 150 | 151 | Notice that states without any substate can just be files, and that there's always an `index.js` within each folder. 152 | 153 | If we explore the contents of the main root `index.js` we can see that it's the starting point for the statechart: 154 | 155 | ```js 156 | import Init from './Init' 157 | import FetchingData from './FetchingData' 158 | 159 | export default { 160 | initial: 'Init', 161 | states: { 162 | ...Init, 163 | ...FetchinData, 164 | } 165 | } 166 | ``` 167 | 168 | Furthemore we can also contain our redux logic within these folders/files: 169 | 170 | ```js 171 | import Init, { 172 | reducer as initReducer, 173 | epic as initEpic, 174 | } from './Init' 175 | 176 | import FetchingData, { 177 | reducer as fetchinDataReducer, 178 | epic as fetchingDataEpic, 179 | } from './FetchingData' 180 | 181 | export const rootEpic = combineEpics( 182 | initEpic, 183 | fetchingDataEpic 184 | ) 185 | 186 | export const rootReducer = combineReducers({ 187 | init: initReducer, 188 | data: fetchingDataReducer 189 | }) 190 | 191 | export default { 192 | initial: 'Init', 193 | states: { 194 | ...Init, 195 | ...FetchinData, 196 | } 197 | } 198 | ``` 199 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-statecharts", 3 | "version": "0.0.1", 4 | "private": true, 5 | "devDependencies": { 6 | "enzyme": "^2.8.2", 7 | "react-scripts": "^1.0.2", 8 | "react-test-renderer": "^15.6.1" 9 | }, 10 | "dependencies": { 11 | "prop-types": "^15.5.10", 12 | "react": "^15.5.0", 13 | "react-dom": "^15.5.0", 14 | "react-redux": "^5.0.5", 15 | "redux": "^3.5.2", 16 | "redux-observable": "^0.17.0", 17 | "rxjs": "^5.5.5", 18 | "xstate": "^2.1.0" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "eject": "react-scripts eject", 24 | "test": "react-scripts test" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redux Counter Example 7 | 8 | 9 |
10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Observable } from 'rxjs'; 4 | import { createStore, combineReducers, applyMiddleware } from 'redux' 5 | import { createEpicMiddleware, combineEpics } from 'redux-observable'; 6 | import { Machine, matchesState } from 'xstate'; 7 | 8 | const machine = Machine({ 9 | initial: 'Init', 10 | states: { 11 | Init: { 12 | on: { 13 | FETCH_DATA_CLICKED: 'FetchingData', 14 | }, 15 | initial: 'NoData', 16 | states: { 17 | ShowData: {}, 18 | Error: {}, 19 | NoData: {} 20 | } 21 | }, 22 | FetchingData: { 23 | on: { 24 | FETCH_DATA_SUCCESS: 'Init.ShowData', 25 | FETCH_DATA_FAILURE: 'Init.Error', 26 | CLICKED_CANCEL: 'Init.NoData', 27 | }, 28 | onEntry: 'FETCH_DATA_REQUEST', 29 | onExit: 'FETCH_DATA_CANCEL', 30 | }, 31 | } 32 | }); 33 | 34 | function handleFetchDataRequest(action$, store) { 35 | return action$.ofType('FETCH_DATA_REQUEST') 36 | .mergeMap(action => 37 | Observable.of(null) 38 | .delay(5000) 39 | .mapTo({ type: 'FETCH_DATA_SUCCESS' }) 40 | .takeUntil(action$.ofType('FETCH_DATA_CANCEL')) 41 | ) 42 | } 43 | 44 | function getStatechartValue(state) { 45 | return state.statechart.value; 46 | } 47 | 48 | function getStatechart(state) { 49 | return state.statechart; 50 | } 51 | 52 | function getStatechartExit(state) { 53 | return state.statechart.effects.exit; 54 | } 55 | function getStatechartEntry(state) { 56 | return state.statechart.effects.entry; 57 | } 58 | 59 | function statechartReducer(state = machine.initial, action) { 60 | const nextState = machine.transition(state, action.type); 61 | if (nextState) { 62 | return nextState.value; 63 | } else { 64 | return state; 65 | } 66 | } 67 | 68 | const rootEpic = combineEpics( 69 | handleFetchDataRequest 70 | ) 71 | 72 | const epicMiddleware = createEpicMiddleware(rootEpic); 73 | 74 | const statechartsMiddleware = store => next => action => { 75 | const state = store.getState(); 76 | const statechart = getStatechart(state) 77 | const nextMachine = machine.transition(statechart, action.type); 78 | 79 | if (!nextMachine) { 80 | return next(action); 81 | } 82 | 83 | // run exists 84 | nextMachine.effects.exit.forEach(actionType => 85 | store.dispatch({ type: actionType }) 86 | ) 87 | 88 | // not supporting onTransition for now 89 | 90 | // then run enters 91 | nextMachine.effects.entry.forEach(actionType => 92 | store.dispatch({ type: actionType }) 93 | ) 94 | 95 | return next(action); 96 | } 97 | 98 | const rootReducer = combineReducers({ 99 | statechart: statechartReducer 100 | }); 101 | const store = createStore( 102 | rootReducer, 103 | applyMiddleware( 104 | statechartsMiddleware, 105 | epicMiddleware 106 | ) 107 | ); 108 | 109 | const rootEl = document.getElementById('root') 110 | 111 | const render = () => { 112 | // console.log(matchesState('Init', getStatechart(store.getState()))) 113 | return ReactDOM.render( 114 |
115 | {JSON.stringify(store.getState())} 116 |
117 | 118 | 119 | 120 |
, 121 | rootEl 122 | ) 123 | } 124 | 125 | render() 126 | store.subscribe(render) 127 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | export default (state = 0, action) => { 2 | switch (action.type) { 3 | case 'INCREMENT': 4 | return state + 1 5 | case 'DECREMENT': 6 | return state - 1 7 | default: 8 | return state 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/reducers/index.spec.js: -------------------------------------------------------------------------------- 1 | import counter from './index' 2 | 3 | describe('reducers', () => { 4 | describe('counter', () => { 5 | it('should provide the initial state', () => { 6 | expect(counter(undefined, {})).toBe(0) 7 | }) 8 | 9 | it('should handle INCREMENT action', () => { 10 | expect(counter(1, { type: 'INCREMENT' })).toBe(2) 11 | }) 12 | 13 | it('should handle DECREMENT action', () => { 14 | expect(counter(1, { type: 'DECREMENT' })).toBe(0) 15 | }) 16 | 17 | it('should ignore unknown actions', () => { 18 | expect(counter(1, { type: 'unknown' })).toBe(1) 19 | }) 20 | }) 21 | }) 22 | --------------------------------------------------------------------------------