lmatteis / redux-statecharts

Redux statecharts
122 stars 6 forks source link

Redux Statechart

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

Install

Create your statechart JSON

const statechart = {
  initial: 'Init',
  states: {
    Init: {
      on: { CLICKED_PLUS: 'Init.Increment' },
      states: {
        Increment: {
          onEntry: INCREMENT
        }
      }
    }
  }
}

Install xstate

Install xstate yarn add xstate and create the machine object

import { Machine } from 'xstate' // yarn add xstate

const machine = Machine(statechart)

The Redux middleware

const UPDATE = '@@statechart/UPDATE'

export const statechartMiddleware = store => next => (action) => {
  const state = store.getState()
  const currentStatechart = state.statechart // this has to match the location where you mount your reducer

  const nextMachine = machine.transition(currentStatechart, action)

  const result = next(action)

  // run actions
  nextMachine.actions.forEach(actionType =>
    store.dispatch({ type: actionType, payload: action.payload }))

  // save current statechart
  if (nextMachine && action.type !== UPDATE) {
    if (nextMachine.history !== undefined) {
      // if there's a history, it means a transition happened
      store.dispatch({ type: UPDATE, payload: nextMachine.value })
    }
  }

  return result
}

Reducer

export function statechartReducer(state = machine.initialState, action) {
  if (action.type === UPDATE) {
    return action.payload
  }
  return state
}

Finally put everything together

const rootReducer = combineReducers({
  statechart: statechartReducer
})

const store = createStore(
  rootReducer,
  applyMiddleware(
    statechartMiddleware,
  ),
)

// make sure your initial state actions are called
machine.initialState.actions.forEach(actionType =>
  store.dispatch({ type: actionType }))

Best practices

Folder structure

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.

For instance, imagine this statechart example:

{
  initial: 'Init',
  states: {
    Init: {
      on: {
        FETCH_DATA_CLICKED: 'FetchingData',
      },
      initial: 'NoData',
      states: {
        ShowData: {},
        Error: {},
        NoData: {}
      }
    },
    FetchingData: {
      on: {
        FETCH_DATA_SUCCESS: 'Init.ShowData',
        FETCH_DATA_FAILURE: 'Init.Error',
        CLICKED_CANCEL: 'Init.NoData',
      },
      onEntry: 'FETCH_DATA_REQUEST',
      onExit: 'FETCH_DATA_CANCEL',
    },
  }
}

One can imagine separating this JSON into several files:

├── FetchingData.js
├── Init
│   ├── Error.js
│   ├── NoData.js
│   ├── ShowData.js
│   └── index.js
└── index.js

Notice that states without any substate can just be files, and that there's always an index.js within each folder.

If we explore the contents of the main root index.js we can see that it's the starting point for the statechart:

import Init from './Init'
import FetchingData from './FetchingData'

export default {
  initial: 'Init',
  states: {
    ...Init,
    ...FetchinData,
  }
}

Furthemore we can also contain our redux logic within these folders/files:

import Init, {
  reducer as initReducer,
  epic as initEpic,
} from './Init'

import FetchingData, {
  reducer as fetchinDataReducer,
  epic as fetchingDataEpic,
} from './FetchingData'

export const rootEpic = combineEpics(
  initEpic,
  fetchingDataEpic
)

export const rootReducer = combineReducers({
  init: initReducer,
  data: fetchingDataReducer
})

export default {
  initial: 'Init',
  states: {
    ...Init,
    ...FetchinData,
  }
}