├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── autobind.js ├── babel.config.js ├── index.js ├── machine.js ├── package-lock.json ├── package.json └── test ├── H2O.js ├── machine.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_script: 3 | - "export DISPLAY=:99.0" 4 | services: 5 | - xvfb 6 | node_js: 7 | - 8 8 | - 10 9 | - node -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mark Stahl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/use-state-machine.svg?color=success&label=size) [![Build Status](https://travis-ci.com/mjstahl/use-state-machine.svg?branch=master)](https://travis-ci.com/mjstahl/use-state-machine) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-blue.svg)](https://standardjs.com) 2 | 3 | # use-state-machine 4 | Use Finite State Machines with React Hooks 5 | 6 | * [Installation](#installation) 7 | * [Example](#example) 8 | * [API](#api) 9 | * [Maintainers](#maintainers) 10 | * [License](#license) 11 | 12 | ## Installation 13 | 14 | ```console 15 | $ npm install --save use-state-machine 16 | ``` 17 | 18 | ## Example 19 | 20 | ```js 21 | // H2O.js 22 | import React from 'react' 23 | import { useStateMachine } from 'use-state-machine' 24 | import H2OState from './H2O.state' 25 | 26 | function H2O () { 27 | const [current, transition] = useStateMachine(H2OState) 28 | return ( 29 |
30 |

Your H2O is in a {current.state} state.

31 |

The temperature of your H2O is {current.value}.

32 | 37 | 42 | 47 |
48 | ) 49 | } 50 | ``` 51 | 52 | ```js 53 | // H2O.state.js 54 | import { StateMachine } from 'use-state-machine' 55 | 56 | export default new StateMachine({ 57 | initial: 'liquid', 58 | liquid: { 59 | freeze: 'solid', 60 | boil: 'gas', 61 | value: '60F' 62 | }, 63 | solid: { 64 | melt: 'liquid', 65 | value: '32F' 66 | }, 67 | gas: { 68 | chill: 'liquid' 69 | value: '212F' 70 | } 71 | }) 72 | ``` 73 | 74 | ## API 75 | 76 | ### useStateMachine 77 | 78 | ```js 79 | import { useStateMachine } from 'use-state-machine' 80 | ``` 81 | 82 | **`useStateMachine(machine: Object | StateMachine) -> [Object, Object]`** 83 | 84 | `useStateMachine` takes a JavaScript object or `StateMachine` Object as an argument and returns an array consisting of a `current` object and a `transition` object. 85 | 86 | 87 | **`current -> Object`** 88 | 89 | The `current` state consists of two properties: `state` and `value`. 90 | `state` returns the string representing the current state. `value` returns the value (object or primitive) of the current state if one exists and returns `undefined` if not. 91 | 92 | ```js 93 | const [ current ] = useStateMachine(H2OState) 94 | 95 | current.state //-> 'liquid' 96 | current.value //-> '60F' 97 | ``` 98 | 99 | **`transition -> Object`** 100 | 101 | `transition` is an object with a collection of functions allowing the developer to avoid 102 | transitioning using the string names. In the example above, when in the `liquid` state, two passive and two active functions exist on `transition`. The passive functions are `transition.toSolid`, `transition.toGas`. The two active functions are `transition.freeze` and `transition.boil`. All state specific functions on `transition` accept a single `value` argument. 103 | 104 | If the value argument is an Object, the state's `value` and value argument will be merged. If the the state's `value` is not an Object, the state's `value` will be replaced with the value argument. If the state's `value` is a primitive and the value argument is an object, the state's `value` will be set to the value argument including a property named `value` set to the state's previous primitive value. 105 | 106 | ```js 107 | const [ current, transition ] = useStateMachine(H2OState) 108 | transition.freeze() 109 | 110 | current.state //-> 'solid' 111 | current.value //-> '32F' 112 | 113 | transition.melt() 114 | 115 | current.state //-> 'liquid' 116 | 117 | transition.toGas() 118 | ``` 119 | 120 | ### StateMachine 121 | 122 | ```js 123 | import { StateMachine } from 'use-state-machine' 124 | ``` 125 | 126 | **`new StateMachine(states: Object) -> StateMachine`** 127 | 128 | To create an instance of a StateMachine pass a 'states' object. A valid 'states' object must have, at a minimum, a single state. And an `initial` property which is set to a valid state property. 129 | 130 | There are two types of `StateMachine` definitions: "active" and passive. If the definition includes names for each valid transition it is an "active" definition and the `transition` property will include "active" functions (like `freeze()` and `boild()`). An example of an "active" definition is: 131 | 132 | ```js 133 | new StateMachine({ 134 | initial: 'liquid', 135 | liquid: { 136 | freeze: 'solid', 137 | boil: 'gas', 138 | value: '60F' 139 | }, 140 | solid: { 141 | melt: 'liquid', 142 | value: '32F' 143 | }, 144 | gas: { 145 | chill: 'liquid' 146 | value: '212F' 147 | } 148 | }) 149 | ``` 150 | 151 | A "passive" definition uses the `to` property on each state indicating one or more valid states the current state can transition to. For a "passive" definition, the `transition` property will only include "passive" functions (like `toSolid` and `toGas`). An example of an "passive" definition is: 152 | 153 | ```js 154 | new StateMachine({ 155 | initial: 'liquid', 156 | liquid: { 157 | to: ['solid', 'gas'] 158 | value: '60F' 159 | }, 160 | solid: { 161 | to: 'liquid' 162 | value: '32F' 163 | }, 164 | gas: { 165 | to: 'liquid' 166 | value: '212F' 167 | } 168 | }) 169 | ``` 170 | 171 | 172 | **`.state -> String`** 173 | 174 | Return the string name of the `StateMachine` state. 175 | 176 | 177 | **`.value -> Any`** 178 | 179 | `value` returns the value (object or primitive) of the current state if one exists and returns `undefined` if not. 180 | 181 | 182 | **`.transition -> Object`** 183 | 184 | `transition` is an object with a collection of functions allowing the developer to avoid 185 | transitioning using the string names. In the example above, when in the `liquid` state, two passive and two active functions exist on `transition`. The passive functions are `transition.toSolid`, `transition.toGas`. The two active functions are `transition.freeze` and `transition.boil`. All state specific functions on `transition` accept a single `value` argument. 186 | 187 | If the value argument is an Object, the state's `value` and value argument will be merged. If the the state's `value` is not an Object, the state's `value` will be replaced with the value argument. If the state's `value` is a primitive and the value argument is an object, the state's `value` will be set to the value argument including a property named `value` set to the state's previous primitive value. 188 | 189 | **`.onTransition(callback: Function) -> unsubscribe: Function`** 190 | 191 | When a `StateMachine` object transitions from one state to another all callbacks passed to the `onTransition` function are evaluated with the `StateMachine` object passed as the only argument to the callback. `onTransition` returns a function that unsubscribes the callback when executed. 192 | 193 | ## Maintainers 194 | 195 | * Mark Stahl 196 | 197 | ## License 198 | 199 | MIT 200 | -------------------------------------------------------------------------------- /autobind.js: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/sindresorhus/auto-bind 2 | module.exports = (self) => { 3 | const proto = self.constructor.prototype 4 | for (let key of Reflect.ownKeys(proto)) { 5 | if (key === 'constructor') { continue } 6 | const descriptor = Reflect.getOwnPropertyDescriptor(proto, key) 7 | if (descriptor && typeof descriptor.value === 'function') { 8 | self[key] = self[key].bind(self) 9 | } 10 | } 11 | return self 12 | } 13 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-react'] 3 | } 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { useState } = require('react') 2 | const StateMachine = require('./machine') 3 | 4 | function useStateMachine (states) { 5 | const machine = (states instanceof StateMachine) 6 | ? states : new StateMachine(states) 7 | const [, setState] = useState(machine.value) 8 | 9 | machine.onTransition((machine) => setState(machine.value)) 10 | 11 | const { state, transition, value } = machine 12 | return [{ state, value }, transition] 13 | } 14 | 15 | module.exports = { StateMachine, useStateMachine } 16 | -------------------------------------------------------------------------------- /machine.js: -------------------------------------------------------------------------------- 1 | const autobind = require('./autobind') 2 | const camelCase = require('lodash.camelcase') 3 | const capitalize = require('lodash.capitalize') 4 | 5 | module.exports = class StateMachine { 6 | constructor (states) { 7 | autobind(this) 8 | 9 | const initial = states.initial 10 | if (!initial || !states[initial]) { 11 | throw new Error('An "initial" property must specify a valid state.') 12 | } 13 | 14 | this._handlers = [] 15 | this._states = states 16 | this.__transition(initial) 17 | } 18 | 19 | get value () { 20 | return this._states[this.state].value 21 | } 22 | 23 | set value (update) { 24 | const value = this._states[this.state].value 25 | const valueIsObject = 26 | Object.getPrototypeOf(value) === Object.prototype 27 | const updateIsValue = 28 | Object.getPrototypeOf(update) === Object.prototype 29 | if (valueIsObject && updateIsValue) { 30 | Object.assign(value, update) 31 | } else if (!valueIsObject && updateIsValue) { 32 | this._states[this.state].value = 33 | Object.assign({ 'value': value }, update) 34 | } else { 35 | this._states[this.state].value = update 36 | } 37 | } 38 | 39 | get transition () { 40 | const fns = {} 41 | Object.keys(fns).forEach(k => delete fns[k]) 42 | 43 | this._possibleStates.reduce((fns, state) => { 44 | fns[`to${capitalize(camelCase(state))}`] = ((to) => { 45 | return (updateValue) => this._transition(to, updateValue) 46 | })(state) 47 | return fns 48 | }, fns) 49 | Object.keys(this._actions).reduce((fns, action) => { 50 | fns[action] = ((to) => { 51 | return (updateValue) => this._transition(to, updateValue) 52 | })(this._actions[action]) 53 | return fns 54 | }, fns) 55 | return fns 56 | } 57 | 58 | onTransition (cb) { 59 | this._handlers.push(cb) 60 | return () => { 61 | return void this._handlers.splice(this._handlers.indexOf(cb) >>> 0, 1) 62 | } 63 | } 64 | 65 | get _actions () { 66 | const state = this._states[this.state] 67 | return Object.keys(state) 68 | .filter(p => !['value', 'to'].includes(p)) 69 | .reduce((actions, a) => { 70 | actions[a] = state[a] 71 | return actions 72 | }, {}) 73 | } 74 | 75 | get _possibleStates () { 76 | const state = this._states[this.state] 77 | let possible = state.to 78 | if (!possible || !possible.length) { 79 | possible = Object.values(this._actions) 80 | } 81 | if (!Array.isArray(possible)) possible = [possible] 82 | return possible 83 | } 84 | 85 | _transition (to, updateValue) { 86 | const available = this._possibleStates.includes(to) 87 | if (!available) { 88 | throw new Error(`"${to}" does not exist as an action of "${this.state}"`) 89 | } 90 | if (!this._states[to]) { 91 | throw new Error(`"${to}" does not exist`) 92 | } 93 | this.__transition(to, updateValue) 94 | } 95 | 96 | __transition (state, updateValue) { 97 | this.state = state 98 | if (updateValue) this.value = updateValue 99 | this._handlers.forEach(h => h(this)) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-state-machine", 3 | "version": "3.0.5", 4 | "description": "Use Finite State Machines with React Hooks", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "standard --fix", 8 | "test": "npm run lint && jest" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/mjstahl/use-state-machine.git" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "hooks", 17 | "fsm", 18 | "statemachine", 19 | "state" 20 | ], 21 | "author": "Mark Stahl ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/mjstahl/use-state-machine/issues" 25 | }, 26 | "homepage": "https://github.com/mjstahl/use-state-machine#readme", 27 | "dependencies": { 28 | "lodash.camelcase": "^4.3.0", 29 | "lodash.capitalize": "^4.2.1", 30 | "react": "^16.9.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/preset-env": "^7.5.5", 34 | "@babel/preset-react": "^7.0.0", 35 | "babel-jest": "^24.9.0", 36 | "babel-polyfill": "^6.26.0", 37 | "jest": "^24.9.0", 38 | "react-dom": "^16.9.0", 39 | "react-testing-library": "^5.9.0", 40 | "standard": "^12.0.1" 41 | }, 42 | "standard": { 43 | "env": [ 44 | "jest" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/H2O.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useStateMachine, StateMachine } from '../index' 3 | 4 | const H2OState = new StateMachine({ 5 | initial: 'liquid', 6 | liquid: { 7 | freeze: 'solid', 8 | boil: 'gas', 9 | value: '60F' 10 | }, 11 | solid: { 12 | to: ['liquid'], 13 | value: '32F' 14 | }, 15 | gas: { 16 | to: 'liquid', 17 | value: '212F' 18 | } 19 | }) 20 | 21 | export default function H2O () { 22 | const [current, transition] = useStateMachine(H2OState) 23 | return ( 24 |
25 |

26 | Your H2O is in a {current.state} state. 27 |

28 |

29 | The temperature of your H2O is {current.value}. 30 |

31 | 36 | 41 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /test/machine.js: -------------------------------------------------------------------------------- 1 | import StateMachine from '../machine' 2 | 3 | function H2O () { 4 | return { 5 | initial: 'liquid', 6 | liquid: { 7 | to: 'solid', 8 | value: '60F' 9 | }, 10 | solid: { 11 | to: ['gas', 'liquid'], 12 | value: '32F' 13 | }, 14 | gas: { 15 | to: 'liquid', 16 | value: { 17 | temp: '212F', 18 | knownAs: 'steam' 19 | } 20 | } 21 | } 22 | } 23 | 24 | test('newly created instance', () => { 25 | const state = new StateMachine(H2O()) 26 | expect(state.state).toBe('liquid') 27 | expect(state.value).toBe('60F') 28 | expect(Object.keys(state.transition).includes('toSolid')).toBeTruthy() 29 | }) 30 | 31 | test('states must specify a valid initial state', () => { 32 | let states = { 33 | liquid: {} 34 | } 35 | expect(() => new StateMachine(states)).toThrow() 36 | states = { 37 | initial: 'solid', 38 | liquid: {} 39 | } 40 | expect(() => new StateMachine(states)).toThrow() 41 | }) 42 | 43 | test('transition to a new state', () => { 44 | const state = new StateMachine(H2O()) 45 | state.transition.toSolid() 46 | expect(state.state).toBe('solid') 47 | expect(state.value).toBe('32F') 48 | expect(Object.keys(state.transition).includes()) 49 | expect(Object.keys(state.transition).includes('toLiquid', 'toGas')).toBeTruthy() 50 | }) 51 | 52 | test('transition is also an object with state functions', () => { 53 | const state = new StateMachine(H2O()) 54 | state.transition.toSolid() 55 | expect(state.state).toBe('solid') 56 | expect(state.value).toBe('32F') 57 | 58 | state.transition.toLiquid('65F') 59 | expect(state.state).toBe('liquid') 60 | expect(state.value).toBe('65F') 61 | }) 62 | 63 | test('update primitive value with a primitive', () => { 64 | const state = new StateMachine(H2O()) 65 | state.transition.toSolid('30F') 66 | expect(state.value).toBe('30F') 67 | }) 68 | 69 | test('update primitive value with an object', () => { 70 | const state = new StateMachine(H2O()) 71 | state.transition.toSolid({ knownAs: 'water' }) 72 | expect(state.value).toEqual({ knownAs: 'water', value: '32F' }) 73 | }) 74 | 75 | test('update object value with an object', () => { 76 | const state = new StateMachine(H2O()) 77 | state.transition.toSolid() 78 | state.transition.toGas({ temp: '213F' }) 79 | expect(state.value).toEqual({ knownAs: 'steam', temp: '213F' }) 80 | }) 81 | 82 | test('states without to states can transition to all states', () => { 83 | const state = new StateMachine({ 84 | initial: 'liquid', 85 | liquid: { 86 | freeze: 'solid', 87 | boil: 'gas', 88 | value: '60F' 89 | }, 90 | solid: { 91 | warm: 'liquid', 92 | value: '32F' 93 | }, 94 | gas: { 95 | chill: 'liquid', 96 | value: '212F' 97 | } 98 | }) 99 | expect(Object.keys(state.transition).includes('toSolid', 'toGas', 'freeze', 'boild')).toBeTruthy() 100 | 101 | state.transition.toGas() 102 | expect(Object.keys(state.transition).includes('toLiquid', 'chill')).toBeTruthy() 103 | expect(state.state).toBe('gas') 104 | }) 105 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import React from 'react' 3 | import { 4 | fireEvent, 5 | render, 6 | wait 7 | } from 'react-testing-library' 8 | 9 | import './machine' 10 | import H2O from './H2O' 11 | 12 | function testParagraphsWith (test, ids, values) { 13 | ids.map((id, index) => { 14 | expect(test(id).innerHTML).toContain(values[index]) 15 | }) 16 | } 17 | 18 | const ids = ['state', 'temp'] 19 | 20 | test('initial rendering', () => { 21 | const { getByTestId } = render() 22 | testParagraphsWith(getByTestId, ids, ['liquid', '60F']) 23 | expect(getByTestId('liquid').disabled).toBeTruthy() 24 | expect(getByTestId('solid').disabled).toBeFalsy() 25 | expect(getByTestId('gas').disabled).toBeFalsy() 26 | }) 27 | 28 | test('transition', async () => { 29 | const { getByTestId } = render() 30 | fireEvent.click(getByTestId('solid')) 31 | await wait(() => { 32 | testParagraphsWith(getByTestId, ids, ['solid', '32F']) 33 | expect(getByTestId('solid').disabled).toBeTruthy() 34 | expect(getByTestId('liquid').disabled).toBeFalsy() 35 | expect(getByTestId('gas').disabled).toBeTruthy() 36 | }) 37 | }) 38 | 39 | test('transition again', async () => { 40 | const { getByTestId } = render() 41 | fireEvent.click(getByTestId('liquid')) 42 | await wait(() => { 43 | testParagraphsWith(getByTestId, ids, ['liquid', '60F']) 44 | expect(getByTestId('gas').disabled).toBeFalsy() 45 | expect(getByTestId('liquid').disabled).toBeTruthy() 46 | expect(getByTestId('solid').disabled).toBeFalsy() 47 | }) 48 | }) 49 | --------------------------------------------------------------------------------