├── .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 |  [](https://travis-ci.com/mjstahl/use-state-machine) [](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 |
--------------------------------------------------------------------------------