├── .circleci └── config.yml ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build └── index.js ├── docs ├── README.md ├── api.md └── examples.md ├── index.d.ts ├── index.js ├── package.json ├── src └── index.js ├── test ├── components │ ├── TrafficLights.js │ └── TrafficLightsWithWalk.js ├── usm-context.test.js ├── usm-nested.test.js ├── usm-revert.test.js └── usm.test.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:10.12 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: yarn install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | # run tests! 37 | - run: yarn test:ci 38 | 39 | - run: 40 | name: Coverage 41 | command: yarn coverage:ci 42 | # environment: 43 | # CODECOV_TOKEN: 1cc28c23-ea43-4955-8260-f5ccf2a08823 44 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 2016, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "indent": [ 14 | "error", 15 | 4 16 | ], 17 | "linebreak-style": [ 18 | "error", 19 | "unix" 20 | ], 21 | "quotes": [ 22 | "error", 23 | "single" 24 | ], 25 | "semi": [ 26 | "error", 27 | "always" 28 | ] 29 | } 30 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .vscode 3 | node_modules 4 | *.map 5 | *.log 6 | 7 | coverage 8 | 9 | tags 10 | tags.temp 11 | tags.lock 12 | 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | docs 3 | src -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at phenax5@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via an issue or any other method with the owners of this repository before making a change. 4 | 5 | Please note we have a [code of conduct](https://github.com/phenax/pipey/blob/master/CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 6 | 7 | ## Pull Request Process 8 | 9 | 1. Create an issue describing the problem or start a discussion on an exisiting, related issue. 10 | 2. Create a pull request. 11 | 3. Don't forget to write tests! 12 | 4. Update the README.md with details of changes to the interface. 13 | 5. You don't have to change the version number. It'll be handled in the publishing stage. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Akshay Nair 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 | 2 | # useTinyStateMachine 3 | A tiny (~700 bytes) react hook to help you write finite state machines 4 | 5 | [![CircleCI](https://img.shields.io/circleci/project/github/phenax/use-tiny-state-machine/master.svg?style=for-the-badge)](https://circleci.com/gh/phenax/use-tiny-state-machine) 6 | [![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/use-tiny-state-machine.svg?style=for-the-badge)](https://www.npmjs.com/package/use-tiny-state-machine) 7 | [![Codecov](https://img.shields.io/codecov/c/github/phenax/use-tiny-state-machine.svg?style=for-the-badge)](https://codecov.io/gh/phenax/use-tiny-state-machine) 8 | 9 | 10 | [Read the documentation for more information](https://github.com/phenax/use-tiny-state-machine/tree/master/docs) 11 | 12 | ## Install 13 | 14 | #### Import 15 | ```bash 16 | yarn add use-tiny-state-machine 17 | ``` 18 | 19 | 20 | ## Examples 21 | 22 | ### Manual traffic lights 23 | 24 | ```js 25 | import useTinyStateMachine from 'use-tiny-state-machine'; 26 | 27 | const stateChart = { 28 | id: 'traffixLight', 29 | initial: 'green', 30 | states: { 31 | green: { on: { NEXT: 'red' } }, 32 | orange: { on: { NEXT: 'green' } }, 33 | red: { on: { NEXT: 'orange' } }, 34 | }, 35 | }; 36 | 37 | export default function ManualTrafficLights() { 38 | const { cata, state, dispatch } = useTinyStateMachine(stateChart); 39 | 40 | return ( 41 | 42 |
52 | The light is {state} 53 |
54 | 55 |
56 | ); 57 | }; 58 | ``` 59 | 60 | 61 | ### Automated traffic lights with onEntry action 62 | `onEntry` is called every time you enter a given state. `onEntry` is called with the current state machine instance. 63 | 64 | ```js 65 | import useTinyStateMachine from 'use-tiny-state-machine'; 66 | 67 | const stateChart = { 68 | id: "traffixLight", 69 | initial: "green", 70 | states: { 71 | green: { 72 | onEntry: waitForNextLight, 73 | on: { 74 | NEXT: "red" 75 | } 76 | }, 77 | orange: { 78 | onEntry: waitForNextLight, 79 | on: { 80 | NEXT: "green" 81 | } 82 | }, 83 | red: { 84 | onEntry: waitForNextLight, 85 | on: { 86 | NEXT: "orange" 87 | } 88 | } 89 | } 90 | }; 91 | 92 | function waitForNextLight({ dispatch }) { 93 | const timer = setTimeout(() => dispatch('NEXT'), 1000); 94 | return () => clearTimeout(timer); 95 | } 96 | 97 | function TrafficLights() { 98 | const { cata, state, dispatch } = useTinyStateMachine(stateChart); 99 | 100 | return ( 101 | 102 |
113 | The light is {state} 114 |
115 | 116 |
117 | ); 118 | } 119 | ``` 120 | 121 | 122 | ### Fetching data 123 | You can use context to store any data associated with a state. 124 | 125 | ```js 126 | const stateChart = { 127 | id: 'userData', 128 | initial: 'idle', 129 | context: { 130 | data: null, 131 | error: null, 132 | }, 133 | states: { 134 | idle: { 135 | on: { 136 | FETCH: { 137 | target: 'pending', 138 | action: ({ dispatch }, userId) => { 139 | fetchUser(userId) 140 | .then(user => dispatch('SUCCESS', user)) 141 | .catch(error => dispatch('FAILURE', error)); 142 | }, 143 | }, 144 | }, 145 | }, 146 | pending: { 147 | on: { 148 | SUCCESS: { 149 | target: 'success', 150 | beforeStateChange: ({ updateContext }, data) => updateContext(c => ({ ...c, data })), 151 | }, 152 | FAILURE: { 153 | target: 'failure', 154 | beforeStateChange: ({ updateContext }, error) => updateContext(c => ({ ...c, error })), 155 | }, 156 | }, 157 | }, 158 | }, 159 | }; 160 | 161 | const UserData = () => { 162 | const { context, dispatch, cata } = useTinyStateMachine(stateChart); 163 | return ( 164 |
165 | {cata({ 166 | idle: () => ( 167 | 170 | ), 171 | pending: () => , 172 | success: () => `Hi ${context.data.name}`, 173 | failure: () => `Error: ${context.error.message}`, 174 | })} 175 |
176 | ); 177 | }; 178 | ``` 179 | 180 | -------------------------------------------------------------------------------- /build/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = useStateMachine; 7 | 8 | var _react = require("react"); 9 | 10 | /* eslint-disable prettier/prettier */ 11 | var sliceArgs = function sliceArgs(args, x, y) { 12 | return [].slice.call(args, x, y); 13 | }; 14 | 15 | function noop() {} 16 | 17 | function callValue(x) { 18 | return typeof x === 'function' ? x.apply(null, sliceArgs(arguments, 1)) : x; 19 | } 20 | 21 | function usePairState(initial) { 22 | var _useState = (0, _react.useState)([initial, null]), 23 | state = _useState[0], 24 | setPairState = _useState[1]; 25 | 26 | var setState = function setState(state) { 27 | return setPairState(function (s) { 28 | return [typeof state === 'function' ? state(s[0]) : state, s[0]]; 29 | }); 30 | }; 31 | 32 | return [state[0], state[1], setState]; 33 | } 34 | 35 | function useStateMachine(stateChart) { 36 | var _usePairState = usePairState(stateChart.initial), 37 | state = _usePairState[0], 38 | prevState = _usePairState[1], 39 | setState = _usePairState[2]; // :: State 40 | 41 | 42 | var _usePairState2 = usePairState(stateChart.context), 43 | context = _usePairState2[0], 44 | prevContext = _usePairState2[1], 45 | updateContext = _usePairState2[2]; // :: State 46 | 47 | 48 | var _useState2 = (0, _react.useState)(null), 49 | pendingAction = _useState2[0], 50 | setPendingAction = _useState2[1]; // :: State<[Function, ...*]> 51 | 52 | 53 | (0, _react.useEffect)(function () { 54 | setState(stateChart.initial); 55 | }, [stateChart.initial]); 56 | (0, _react.useEffect)(function () { 57 | var _stateChart$states$st = stateChart.states[state]; 58 | _stateChart$states$st = _stateChart$states$st === void 0 ? {} : _stateChart$states$st; 59 | var onEntry = _stateChart$states$st.onEntry; 60 | var destroy = (onEntry || noop)(stateMachine, state); 61 | return typeof destroy === 'function' ? destroy : function () {}; 62 | }, [state]); 63 | (0, _react.useEffect)(function () { 64 | if (!pendingAction) return noop; 65 | var action = pendingAction[0], 66 | _pendingAction$ = pendingAction[1], 67 | args = _pendingAction$ === void 0 ? [] : _pendingAction$; 68 | setPendingAction(null); 69 | var destroy = (action || noop).apply(null, [stateMachine].concat(args)); 70 | return typeof destroy === 'function' ? destroy : function () {}; 71 | }, [pendingAction]); // dispatch :: (String, ...*) -> () 72 | 73 | function dispatch(transitionName) { 74 | var args = sliceArgs(arguments, 1); 75 | var stateTransitions = stateChart.states[state]; 76 | 77 | var _ref = stateTransitions || {}, 78 | _ref$on = _ref.on; 79 | 80 | _ref$on = _ref$on === void 0 ? {} : _ref$on; 81 | var transition = _ref$on[transitionName]; 82 | 83 | var _ref2 = (typeof transition === "string" ? { 84 | target: transition 85 | } : transition) || {}, 86 | target = _ref2.target, 87 | action = _ref2.action, 88 | newContext = _ref2.context, 89 | beforeStateChange = _ref2.beforeStateChange; 90 | 91 | if (!stateTransitions || !(target || action)) { 92 | throw new Error("Invalid chart as transition \"".concat(transitionName, "\" not available for state \"").concat(state, "\"")); 93 | } // TODO: Cleanup for beforeStateChange 94 | 95 | 96 | beforeStateChange && beforeStateChange.apply(null, [stateMachine].concat(args)); 97 | action && setPendingAction([action, args]); 98 | target && setState(target); 99 | newContext && updateContext(newContext); 100 | } 101 | 102 | function revertToLastState() { 103 | prevState && setState(prevState); 104 | prevContext && updateContext(prevContext); 105 | } // cata :: { [key: String]: String -> b } -> b 106 | 107 | 108 | var cata = function cata(pattern) { 109 | return callValue(state in pattern ? pattern[state] : pattern._, stateMachine); 110 | }; // matches :: String -> Boolean 111 | 112 | 113 | var matches = function matches(x) { 114 | return x === state; 115 | }; 116 | 117 | var stateMachine = { 118 | id: stateChart.id, 119 | state: state, 120 | dispatch: dispatch, 121 | context: context, 122 | updateContext: updateContext, 123 | cata: cata, 124 | matches: matches, 125 | revertToLastState: revertToLastState 126 | }; 127 | return stateMachine; 128 | } 129 | 130 | ; -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # useTinyStateMachine 2 | A lightweight react hook to create and work with state machines 3 | 4 | [![CircleCI](https://img.shields.io/circleci/project/github/phenax/use-tiny-state-machine/master.svg?style=for-the-badge)](https://circleci.com/gh/phenax/use-tiny-state-machine) 5 | [![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/use-tiny-state-machine.svg?style=for-the-badge)](https://www.npmjs.com/package/use-tiny-state-machine) 6 | [![Codecov](https://img.shields.io/codecov/c/github/phenax/use-tiny-state-machine.svg?style=for-the-badge)](https://codecov.io/gh/phenax/use-tiny-state-machine) 7 | 8 | 9 | ## Install 10 | 11 | #### Import 12 | ```bash 13 | yarn add use-tiny-state-machine 14 | ``` 15 | 16 | ## Motivation 17 | 18 | This library is heavily inspired by [xstate](https://github.com/davidkpiano/xstate). XState works beautifully for most use cases but the size of the library comes around to [12.2KB](https://bundlephobia.com/result?p=xstate@4.4.0) minified and gzipped, which I've found is a bit hard to justify using in a small project. This library was written for the sole purpose of being a tiniest version of xstate focused on its usage as a react hooks. 19 | 20 | 21 | ## Learn more 22 | 23 | * [API docs](./api.md) 24 | * [Some examples](./examples.md) 25 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ```haskell 4 | useTinyStateMachine :: StateChart -> StateMachine 5 | ``` 6 | 7 | ```js 8 | const Component = () => { 9 | const { matches, cata, dispatch, state } = useTinyStateMachine(stateChart); 10 | return ( 11 | /* ... */ 12 | ); 13 | }; 14 | ``` 15 | 16 | ### StateChart instance/Options 17 | 18 | ```js 19 | const stateChart = { 20 | id: 'trafficLight', 21 | initial: 'green', // Start with a green light 22 | states: { 23 | green: { 24 | on: { 25 | NEXT: 'red', // `NEXT: 'red'` is a shorthand for `NEXT: { target: 'red' }` 26 | }, 27 | }, 28 | orange: { 29 | on: { 30 | NEXT: { 31 | target: 'green', 32 | beforeStateChange: () => console.log('Get ready to go'), 33 | action: () => console.log('Time to hit the nitro!'), 34 | }, 35 | }, 36 | }, 37 | red: { 38 | onEntry: () => console.log('STOP! Its a red light'), 39 | on: { 40 | NEXT: 'orange', 41 | }, 42 | }, 43 | }, 44 | }; 45 | ``` 46 | 47 | NOTE: You can return a clean up function from inside both `action` and `onEntry`. 48 | ```js 49 | const onEntry = () => { 50 | const timer = setTimeout(() => {/* do stuff */}, 1000); 51 | return () => clearTimeout(timer); 52 | }; 53 | ``` 54 | 55 | 56 | ### StateMachine instance 57 | 58 | * `state` - The current state of the machine. 59 | ```haskell 60 | state :: String 61 | ``` 62 | 63 | 64 | * `dispatch` - Dispatch/Call a transition to either change state or call an action. 65 | ```haskell 66 | dispatch :: (String, ...*) -> () 67 | ``` 68 | 69 | ```js 70 | const onClick = () => dispatch('NEXT'); 71 | ``` 72 | 73 | 74 | * `matches` - Returns true if current state is equal to given state 75 | ```haskell 76 | matches :: String -> Boolean 77 | ``` 78 | ```js 79 | if (matches('red')) { 80 | console.log('STOP!'); 81 | } 82 | ``` 83 | 84 | 85 | * `cata` - Pattern match the current state to a function or value 86 | ```haskell 87 | cata :: Object (b | String -> b) -> b 88 | ``` 89 | 90 | ```js 91 | // Handlers can be values or functions returning a value 92 | const trafficMessage = cata({ 93 | red: 'Stop', 94 | orange: () => 'Go slow', 95 | green: 'Go! Go! Go!', 96 | }); 97 | ``` 98 | 99 | ```js 100 | // You can also have a default handler using `_` 101 | const pedestrianMessage = cata({ 102 | red: 'Walk', 103 | _: 'Stop', 104 | }); 105 | ``` 106 | 107 | 108 | * `context` - Additional state data associated with the state machine 109 | ```haskell 110 | context :: Object * 111 | ``` 112 | 113 | 114 | * `updateContext` - Additional state data associated with the state machine 115 | ```haskell 116 | updateContext :: (Object a | Object a -> Object a) -> () 117 | ``` 118 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ### Manual traffic lights 4 | 5 | ```js 6 | import useTinyStateMachine from 'use-tiny-state-machine'; 7 | 8 | const stateChart = { 9 | id: 'traffixLight', 10 | initial: 'green', 11 | states: { 12 | green: { on: { NEXT: 'red' } }, 13 | orange: { on: { NEXT: 'green' } }, 14 | red: { on: { NEXT: 'orange' } }, 15 | }, 16 | }; 17 | 18 | export default function ManualTrafficLights() { 19 | const { cata, state, dispatch } = useTinyStateMachine(stateChart); 20 | 21 | return ( 22 | 23 |
33 | The light is {state} 34 |
35 | 36 |
37 | ); 38 | }; 39 | ``` 40 | 41 | 42 | ### Automated traffic lights with onEntry action 43 | `onEntry` is called every time you enter a given state. `onEntry` is called with the current state machine instance. 44 | 45 | ```js 46 | import useTinyStateMachine from 'use-tiny-state-machine'; 47 | 48 | const stateChart = { 49 | id: "traffixLight", 50 | initial: "green", 51 | states: { 52 | green: { 53 | onEntry: waitForNextLight, 54 | on: { 55 | NEXT: "red" 56 | } 57 | }, 58 | orange: { 59 | onEntry: waitForNextLight, 60 | on: { 61 | NEXT: "green" 62 | } 63 | }, 64 | red: { 65 | onEntry: waitForNextLight, 66 | on: { 67 | NEXT: "orange" 68 | } 69 | } 70 | } 71 | }; 72 | 73 | function waitForNextLight({ dispatch }) { 74 | const timer = setTimeout(() => dispatch('NEXT'), 1000); 75 | return () => clearTimeout(timer); 76 | } 77 | 78 | function TrafficLights() { 79 | const { cata, state, dispatch } = useTinyStateMachine(stateChart); 80 | 81 | return ( 82 | 83 |
94 | The light is {state} 95 |
96 | 97 |
98 | ); 99 | } 100 | ``` 101 | 102 | ### Automated traffic lights with transition actions 103 | The difference between using action and onEntry is that onEntry is called every time you enter a particular state where as transition actions are called when a particular action is triggered. The target property does the same as what it did in the previous examples. 104 | Transition action is called with the state machine instance as well as any left over arguements from the disptach call. 105 | 106 | In the example below, onNext will be called whenever the `NEXT` transition is dispatched. 107 | 108 | ```js 109 | const stateChart = { 110 | id: "traffixLight", 111 | initial: "green", 112 | states: { 113 | green: { 114 | on: { 115 | NEXT: { 116 | target: "red", 117 | action: onNext, 118 | }, 119 | } 120 | }, 121 | orange: { 122 | on: { 123 | NEXT: { 124 | target: "green", 125 | action: onNext, 126 | }, 127 | } 128 | }, 129 | red: { 130 | on: { 131 | NEXT: { 132 | target: "orange", 133 | action: onNext, 134 | }, 135 | } 136 | } 137 | } 138 | }; 139 | 140 | function onNext({ dispatch }) { 141 | const timer = setTimeout(() => dispatch('NEXT'), 1000); 142 | return () => clearTimeout(timer); 143 | } 144 | ``` 145 | 146 | 147 | ### Multiple linked state charts 148 | Unlike xstate, this library only supports 1D level of nesting for state machines but you can achieve that by using multiple `useTinyStateMachine`. 149 | 150 | ```js 151 | const TrafficLight = ({ onEntry }) => { 152 | const trafficLight = useTinyStateMachine({ 153 | id: 'trafficLight', 154 | initial: 'green', 155 | states: { 156 | green: { on: { NEXT: 'red' } }, 157 | red: { on: { NEXT: 'orange' } }, 158 | orange: { on: { NEXT: 'green' } }, 159 | }, 160 | }); 161 | 162 | const pedestrianSign = useTinyStateMachine({ 163 | id: 'pedestrianSign', 164 | initial: trafficLight.cata({ 165 | red: 'walk', 166 | _: 'stop', 167 | }), 168 | states: { 169 | stop: { 170 | onEntry, 171 | on: { 172 | WALK: { 173 | action: () => { 174 | const timer = setTimeout(() => trafficLight.dispatch('NEXT'), 1000); 175 | return () => clearTimeout(timer); 176 | }, 177 | }, 178 | }, 179 | }, 180 | walk: { 181 | onEntry, 182 | on: { 183 | WALK: { action: () => {} }, 184 | }, 185 | }, 186 | }, 187 | }); 188 | 189 | const onWalkButtonClick = pedestrianSign.dispatch('WALK'); 190 | 191 | return ( 192 | /* ... */ 193 | ); 194 | }; 195 | 196 | ``` 197 | 198 | 199 | 200 | ### Fetching data 201 | You can use context to store any data associated with a state. 202 | 203 | ```js 204 | const stateChart = { 205 | id: 'userData', 206 | initial: 'idle', 207 | context: { 208 | data: null, 209 | error: null, 210 | }, 211 | states: { 212 | idle: { 213 | on: { 214 | FETCH: { 215 | target: 'pending', 216 | action: ({ dispatch }, userId) => { 217 | fetchUser(userId) 218 | .then(user => dispatch('SUCCESS', user)) 219 | .catch(error => dispatch('FAILURE', error)); 220 | }, 221 | }, 222 | }, 223 | }, 224 | pending: { 225 | on: { 226 | SUCCESS: { 227 | target: 'success', 228 | beforeStateChange: ({ updateContext }, data) => updateContext(c => ({ ...c, data })), 229 | }, 230 | FAILURE: { 231 | target: 'failure', 232 | beforeStateChange: ({ updateContext }, error) => updateContext(c => ({ ...c, error })), 233 | }, 234 | }, 235 | }, 236 | }, 237 | }; 238 | 239 | const UserData = () => { 240 | const { context, dispatch, cata } = useTinyStateMachine(stateChart); 241 | return ( 242 |
243 | {cata({ 244 | idle: () => ( 245 | 248 | ), 249 | pending: () => , 250 | success: () => `Hi ${context.data.name}`, 251 | failure: () => `Error: ${context.error.message}`, 252 | })} 253 |
254 | ); 255 | }; 256 | ``` 257 | 258 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module "use-tiny-state-machine" { 3 | type MaybeFunction = R | ((a: T) => R) 4 | export type CataPattern = 5 | { [key in T | '_']: MaybeFunction }; 6 | 7 | export type StateMachine = { 8 | id: string 9 | state: S 10 | dispatch: (t: T, ...args: any[]) => void 11 | context: C 12 | updateContext: (c: C | ((prev: C) => C)) => void 13 | cata: (pattern: CataPattern) => R 14 | matches: (s: S) => boolean 15 | revertToLastState: () => void 16 | }; 17 | 18 | export type TransitionTarget = S | { 19 | target: S 20 | beforeStateChange?: (sm: StateMachine) => any 21 | }; 22 | 23 | export type StateTransitionMapping = { 24 | [key in S]?: { 25 | onEntry?: (sm: StateMachine) => any 26 | on?: { 27 | [key in T]?: TransitionTarget 28 | } 29 | } 30 | }; 31 | 32 | export type StateChart = { 33 | id?: string 34 | initial: S 35 | context?: C 36 | states: StateTransitionMapping 37 | }; 38 | 39 | export default function useTinyStateMachine(stateChart: StateChart): StateMachine; 40 | } 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = require('./build/index'); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-tiny-state-machine", 3 | "version": "0.0.2", 4 | "description": "A tiny (~700 bytes) react hook to help you write finite state machines", 5 | "main": "index.js", 6 | "repository": "https://github.com/phenax/use-tiny-state-machine", 7 | "author": "Akshay Nair ", 8 | "license": "MIT", 9 | "keywords": [ 10 | "functional", 11 | "finite", 12 | "state-machine", 13 | "hooks", 14 | "react", 15 | "functional-programming", 16 | "fp", 17 | "js" 18 | ], 19 | "scripts": { 20 | "build": "babel src --out-dir build", 21 | "test": "jest --watch", 22 | "test:ci": "jest --coverage", 23 | "coverage:ci": "codecov" 24 | }, 25 | "babel": { 26 | "presets": [ 27 | "@babel/preset-env", 28 | "@babel/preset-react" 29 | ], 30 | "plugins": [ 31 | "@babel/plugin-proposal-class-properties" 32 | ] 33 | }, 34 | "devDependencies": { 35 | "@babel/cli": "^7.1.0", 36 | "@babel/core": "^7.1.0", 37 | "@babel/plugin-proposal-class-properties": "^7.1.0", 38 | "@babel/plugin-proposal-pipeline-operator": "^7.0.0", 39 | "@babel/preset-env": "^7.1.0", 40 | "@babel/preset-react": "^7.0.0", 41 | "babel-core": "^7.0.0-bridge", 42 | "babel-jest": "^23.6.0", 43 | "codecov": "^3.1.0", 44 | "jest": "^23.6.0", 45 | "react": "^16.8.0", 46 | "react-test-render-fns": "^0.0.0", 47 | "terser": "^3.17.0" 48 | }, 49 | "peerDependencies": { 50 | "react": ">=16" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable prettier/prettier */ 2 | import { useState, useEffect } from 'react'; 3 | 4 | const sliceArgs = (args, x, y) => [].slice.call(args, x, y); 5 | 6 | function noop() {} 7 | function callValue(x) { 8 | return typeof x === 'function' ? x.apply(null, sliceArgs(arguments, 1)) : x; 9 | } 10 | 11 | function usePairState(initial) { 12 | const { 0: state, 1: setPairState } = useState([initial, null]); 13 | const setState = state => setPairState(s => [ 14 | typeof state === 'function' ? state(s[0]) : state, 15 | s[0], 16 | ]); 17 | return [ state[0], state[1], setState ]; 18 | } 19 | 20 | export default function useStateMachine(stateChart) { 21 | const { 0: state, 1: prevState, 2: setState } = usePairState(stateChart.initial); // :: State 22 | const { 0: context, 1: prevContext, 2: updateContext } = usePairState(stateChart.context); // :: State 23 | const { 0: pendingAction, 1: setPendingAction } = useState(null); // :: State<[Function, ...*]> 24 | 25 | useEffect(() => { 26 | setState(stateChart.initial); 27 | }, [stateChart.initial]); 28 | 29 | useEffect(() => { 30 | const { [state]: { onEntry } = {} } = stateChart.states; 31 | const destroy = (onEntry || noop)(stateMachine, state); 32 | return typeof destroy === 'function' ? destroy : (() => {}); 33 | }, [state]); 34 | 35 | useEffect(() => { 36 | if (!pendingAction) return noop; 37 | const { 0: action, 1: args = [] } = pendingAction; 38 | setPendingAction(null); 39 | const destroy = (action || noop).apply(null, [stateMachine].concat(args)); 40 | return typeof destroy === 'function' ? destroy : (() => {}); 41 | }, [pendingAction]); 42 | 43 | // dispatch :: (String, ...*) -> () 44 | function dispatch(transitionName) { 45 | const args = sliceArgs(arguments, 1); 46 | const stateTransitions = stateChart.states[state]; 47 | const { on: { [transitionName]: transition } = {} } = stateTransitions || {}; 48 | const { target, action, context: newContext, beforeStateChange } = 49 | (typeof transition === "string" ? { target: transition } : transition) || {}; 50 | 51 | if (!stateTransitions || !(target || action)) { 52 | throw new Error( 53 | `Invalid chart as transition "${transitionName}" not available for state "${state}"` 54 | ); 55 | } 56 | 57 | // TODO: Cleanup for beforeStateChange 58 | beforeStateChange && beforeStateChange.apply(null, [stateMachine].concat(args)); 59 | action && setPendingAction([action, args]); 60 | target && setState(target); 61 | newContext && updateContext(newContext); 62 | } 63 | 64 | function revertToLastState() { 65 | prevState && setState(prevState); 66 | prevContext && updateContext(prevContext); 67 | } 68 | 69 | // cata :: { [key: String]: String -> b } -> b 70 | const cata = pattern => callValue(state in pattern ? pattern[state] : pattern._, stateMachine); 71 | 72 | // matches :: String -> Boolean 73 | const matches = x => x === state; 74 | 75 | const stateMachine = { 76 | id: stateChart.id, 77 | state, dispatch, 78 | context, updateContext, 79 | cata, matches, 80 | revertToLastState, 81 | }; 82 | 83 | return stateMachine; 84 | }; 85 | -------------------------------------------------------------------------------- /test/components/TrafficLights.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useStateMachine from '../../src'; 4 | 5 | // eslint-disable-next-line react/prop-types 6 | const TrafficLight = ({ onNext = () => {} }) => { 7 | const trafficLight = useStateMachine({ 8 | id: 'traffixLight', 9 | initial: 'green', 10 | states: { 11 | green: { 12 | on: { 13 | NEXT: { 14 | target: 'red', 15 | action: onNext.bind(null, 'green'), 16 | }, 17 | }, 18 | }, 19 | red: { 20 | on: { 21 | NEXT: { 22 | target: 'orange', 23 | action: onNext.bind(null, 'red'), 24 | }, 25 | }, 26 | }, 27 | orange: { 28 | on: { 29 | NEXT: { 30 | target: 'green', 31 | action: onNext.bind(null, 'orange'), 32 | }, 33 | }, 34 | }, 35 | }, 36 | }); 37 | 38 | return ( 39 |
40 |
{trafficLight.state}
41 |
42 | {trafficLight.cata({ 43 | green: () => 'green color', 44 | orange: () => 'orange color', 45 | red: () => 'red color', 46 | })} 47 |
48 |
49 | 52 | 55 |
56 |
57 | 60 |
61 |
62 | ); 63 | }; 64 | 65 | it('-', () => {}); 66 | 67 | export default TrafficLight; 68 | -------------------------------------------------------------------------------- /test/components/TrafficLightsWithWalk.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useStateMachine from '../../src'; 4 | 5 | // eslint-disable-next-line react/prop-types 6 | const TrafficLight = ({ onEntry }) => { 7 | const trafficLight = useStateMachine({ 8 | id: 'traffixLight', 9 | initial: 'green', 10 | states: { 11 | green: { on: { NEXT: 'red' } }, 12 | red: { on: { NEXT: 'orange' } }, 13 | orange: { on: { NEXT: 'green' } }, 14 | }, 15 | }); 16 | 17 | const walkSign = useStateMachine({ 18 | id: 'walkSign', 19 | initial: trafficLight.cata({ 20 | red: 'walk', 21 | _: 'stop', 22 | }), 23 | states: { 24 | stop: { 25 | onEntry, 26 | on: { 27 | WALK: { 28 | action: () => { 29 | const timer = setTimeout(() => trafficLight.dispatch('NEXT'), 1000); 30 | return () => clearTimeout(timer); 31 | }, 32 | }, 33 | }, 34 | }, 35 | walk: { 36 | onEntry, 37 | on: { 38 | WALK: { action: () => {} }, 39 | }, 40 | }, 41 | }, 42 | }); 43 | 44 | return ( 45 |
46 |
{trafficLight.state}
47 |
{walkSign.state}
48 |
49 | 52 |
53 |
54 | ); 55 | }; 56 | 57 | it('-', () => {}); 58 | 59 | export default TrafficLight; 60 | -------------------------------------------------------------------------------- /test/usm-context.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { mount, byId, text, simulate, compose, find, runAllTimers } from 'react-test-render-fns'; 4 | 5 | import useStateMachine from '../src'; 6 | 7 | jest.useFakeTimers(); 8 | 9 | describe('useStateMachine with context', () => { 10 | const stateChart = { 11 | id: 'userData', 12 | initial: 'idle', 13 | context: { 14 | data: null, 15 | error: null, 16 | }, 17 | states: { 18 | idle: { 19 | on: { 20 | FETCH: { 21 | target: 'pending', 22 | action: ({ dispatch }, userId) => { 23 | setTimeout(() => { 24 | if (userId === 'other') { 25 | return dispatch('FAILURE', new Error('User not found')); 26 | } 27 | return dispatch('SUCCESS', { userId, name: 'Akshay Nair' }); 28 | }, 100); 29 | }, 30 | }, 31 | }, 32 | }, 33 | pending: { 34 | on: { 35 | SUCCESS: { 36 | target: 'success', 37 | beforeStateChange: ({ updateContext }, data) => updateContext(c => ({ ...c, data })), 38 | }, 39 | FAILURE: { 40 | target: 'failure', 41 | beforeStateChange: ({ updateContext }, error) => updateContext(c => ({ ...c, error })), 42 | }, 43 | }, 44 | }, 45 | }, 46 | }; 47 | 48 | const Compo = () => { 49 | const { context, dispatch, cata } = useStateMachine(stateChart); 50 | return ( 51 |
52 | 55 | 58 |
59 | {cata({ 60 | idle: () => 'Hey there', 61 | pending: () => 'Loading...', 62 | // TODO: Fix intermediate state issue 63 | success: () => `Hi ${context.data.name}`, 64 | failure: () => `Error: ${context.error.message}`, 65 | })} 66 |
67 |
68 | ); 69 | }; 70 | 71 | const clickBtn = ($root, buttonType) => { 72 | const $btn = $root.find(byId(buttonType)); 73 | simulate(new Event('click'), $btn); 74 | }; 75 | 76 | const getMessage = compose(text, find(byId('text'))); 77 | 78 | it('should go from idle to pending to success with the FETCH phenax dispatch', () => { 79 | const $root = mount(); 80 | 81 | expect(getMessage($root)).toBe('Hey there'); 82 | clickBtn($root, 'fetchBtnPhenax'); 83 | expect(getMessage($root)).toBe('Loading...'); 84 | runAllTimers(); 85 | expect(getMessage($root)).toBe('Hi Akshay Nair'); 86 | }); 87 | 88 | it('should go from idle to pending to failure with the FETCH other dispatch', () => { 89 | const $root = mount(); 90 | 91 | expect(getMessage($root)).toBe('Hey there'); 92 | clickBtn($root, 'fetchBtnOther'); 93 | expect(getMessage($root)).toBe('Loading...'); 94 | runAllTimers(); 95 | expect(getMessage($root)).toBe('Error: User not found'); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/usm-nested.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | 4 | import { mount, byId, text, simulate, runAllTimers } from 'react-test-render-fns'; 5 | 6 | import TrafficLights from './components/TrafficLights'; 7 | import TrafficLightsWithWalk from './components/TrafficLightsWithWalk'; 8 | 9 | jest.useFakeTimers(); 10 | 11 | const clickBtn = ($root, buttonType) => { 12 | const $btn = $root.find(byId(buttonType)); 13 | simulate(new Event('click'), $btn); 14 | }; 15 | 16 | describe('useStateMachine with linked state', () => { 17 | const onEntry = jest.fn(); 18 | const checkState = (light, $root) => { 19 | expect(text($root.find(byId('light')))).toBe(light); 20 | expect(text($root.find(byId('walk')))).toBe(light === 'red' ? 'walk' : 'stop'); 21 | }; 22 | 23 | beforeEach(() => { 24 | onEntry.mockClear(); 25 | }); 26 | 27 | it('should be green', () => { 28 | const $root = mount(); 29 | expect(onEntry).toHaveBeenCalledTimes(1); 30 | checkState('green', $root); 31 | }); 32 | 33 | it('should turn to red after 1 second when walk button is clicked', () => { 34 | const $root = mount(); 35 | expect(onEntry).toHaveBeenCalledTimes(1); 36 | checkState('green', $root); 37 | clickBtn($root, 'walkBtn'); 38 | checkState('green', $root); 39 | runAllTimers(); 40 | runAllTimers(); 41 | checkState('red', $root); 42 | expect(onEntry).toHaveBeenCalledTimes(2); 43 | }); 44 | 45 | it('should call next only once even if walk button is pressed multiple times (action cancellation)', () => { 46 | const $root = mount(); 47 | expect(onEntry).toHaveBeenCalledTimes(1); 48 | clickBtn($root, 'walkBtn'); 49 | clickBtn($root, 'walkBtn'); 50 | clickBtn($root, 'walkBtn'); 51 | clickBtn($root, 'walkBtn'); 52 | clickBtn($root, 'walkBtn'); 53 | clickBtn($root, 'walkBtn'); 54 | clickBtn($root, 'walkBtn'); 55 | checkState('green', $root); 56 | runAllTimers(); 57 | runAllTimers(); 58 | checkState('red', $root); 59 | expect(onEntry).toHaveBeenCalledTimes(2); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/usm-revert.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | 4 | import { mount, byId, text, simulate, runAllTimers } from 'react-test-render-fns'; 5 | 6 | import TrafficLights from './components/TrafficLights'; 7 | import TrafficLightsWithWalk from './components/TrafficLightsWithWalk'; 8 | 9 | jest.useFakeTimers(); 10 | 11 | const clickBtn = (buttonType, $root) => { 12 | const $btn = $root.find(byId(buttonType)); 13 | simulate(new Event('click'), $btn); 14 | }; 15 | 16 | describe('useStateMachine single state', () => { 17 | const checkState = (state, $root) => { 18 | expect(text($root.find(byId('state')))).toBe(state); 19 | expect(text($root.find(byId('cata')))).toBe(`${state} color`); 20 | }; 21 | 22 | it('should be red after click', () => { 23 | const $root = mount(); 24 | checkState('green', $root); 25 | 26 | clickBtn('next', $root); 27 | checkState('red', $root); 28 | 29 | clickBtn('revert', $root); 30 | checkState('green', $root); 31 | }); 32 | }); -------------------------------------------------------------------------------- /test/usm.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | import React from 'react'; 3 | 4 | import { mount, byId, text, simulate, runAllTimers } from 'react-test-render-fns'; 5 | 6 | import TrafficLights from './components/TrafficLights'; 7 | import TrafficLightsWithWalk from './components/TrafficLightsWithWalk'; 8 | 9 | jest.useFakeTimers(); 10 | 11 | const clickBtn = ($root, buttonType) => { 12 | const $btn = $root.find(byId(buttonType)); 13 | simulate(new Event('click'), $btn); 14 | }; 15 | 16 | describe('useStateMachine single state', () => { 17 | const checkState = (state, $root) => { 18 | expect(text($root.find(byId('state')))).toBe(state); 19 | expect(text($root.find(byId('cata')))).toBe(`${state} color`); 20 | }; 21 | 22 | it('should be green', () => { 23 | const $root = mount(); 24 | checkState('green', $root); 25 | }); 26 | 27 | it('should be red after click', () => { 28 | const $root = mount(); 29 | clickBtn($root, 'next'); 30 | checkState('red', $root); 31 | }); 32 | 33 | it('should be orange after 2 clicks', () => { 34 | const $root = mount(); 35 | clickBtn($root, 'next'); 36 | clickBtn($root, 'next'); 37 | checkState('orange', $root); 38 | }); 39 | 40 | it('should be green after 2 clicks', () => { 41 | const $root = mount(); 42 | clickBtn($root, 'next'); 43 | clickBtn($root, 'next'); 44 | clickBtn($root, 'next'); 45 | checkState('green', $root); 46 | }); 47 | 48 | it('should call action on the specific transitions', () => { 49 | const onNext = jest.fn(); 50 | const $root = mount(); 51 | 52 | expect(onNext).not.toHaveBeenCalled(); 53 | clickBtn($root, 'next'); 54 | expect(onNext).toHaveBeenCalledTimes(1); 55 | expect(onNext.mock.calls[0][0]).toBe('green'); 56 | expect(onNext.mock.calls[0][2]).toBe(1); 57 | expect(onNext.mock.calls[0][3]).toBe(2); 58 | clickBtn($root, 'next'); 59 | expect(onNext).toHaveBeenCalledTimes(2); 60 | expect(onNext.mock.calls[1][0]).toBe('red'); 61 | expect(onNext.mock.calls[1][2]).toBe(1); 62 | expect(onNext.mock.calls[1][3]).toBe(2); 63 | clickBtn($root, 'next'); 64 | expect(onNext).toHaveBeenCalledTimes(3); 65 | expect(onNext.mock.calls[2][0]).toBe('orange'); 66 | expect(onNext.mock.calls[1][2]).toBe(1); 67 | expect(onNext.mock.calls[1][3]).toBe(2); 68 | clickBtn($root, 'next'); 69 | expect(onNext).toHaveBeenCalledTimes(4); 70 | expect(onNext.mock.calls[3][0]).toBe('green'); 71 | expect(onNext.mock.calls[1][2]).toBe(1); 72 | expect(onNext.mock.calls[1][3]).toBe(2); 73 | }); 74 | 75 | it('should throw error on invalid transitions and state should remain unchanged', () => { 76 | const $root = mount(); 77 | expect(() => clickBtn($root, 'prev')).toThrowError(); 78 | checkState('green', $root); 79 | }); 80 | }); 81 | --------------------------------------------------------------------------------