├── .travis.yml ├── .gitignore ├── .editorconfig ├── jest.config.js ├── tsconfig.json ├── LICENSE ├── package.json ├── src ├── index.ts └── index.spec.tsx └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | .jest/ 4 | coverage/ 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*.{js,jsx,ts,tsx}] 3 | 4 | indent_style = space 5 | indent_size = 2 6 | 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ["ts", "tsx", "js"], 3 | modulePaths: [""], 4 | transform: { 5 | "\\.(ts|tsx)$": "ts-jest", 6 | }, 7 | testRegex: "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 8 | testPathIgnorePatterns: ["\\.snap$", "/node_modules/"], 9 | cacheDirectory: ".jest/cache", 10 | collectCoverageFrom: ["src/**.{ts,tsx}"], 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "jsx": "react", 7 | "declaration": true, 8 | "pretty": true, 9 | "rootDir": "src", 10 | "sourceMap": false, 11 | "strict": true, 12 | "noUnusedLocals": false, 13 | "noUnusedParameters": false, 14 | "noImplicitReturns": true, 15 | "noImplicitAny": false, 16 | "noFallthroughCasesInSwitch": true, 17 | "outDir": "lib", 18 | "lib": ["es2018", "dom"] 19 | }, 20 | "exclude": [ 21 | "node_modules", 22 | "lib", 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-present Carlos Galarza 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-machine", 3 | "version": "1.1.3", 4 | "description": "Use Statecharts in React powered by XState", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib/" 8 | ], 9 | "types": "lib/index.d.ts", 10 | "typings": "lib/index.d.ts", 11 | "scripts": { 12 | "test": "node_modules/.bin/jest --no-cache --coverage", 13 | "build": "tsc" 14 | }, 15 | "peerDependencies": { 16 | "react": "^16.12.0", 17 | "react-dom": "^16.12.0", 18 | "xstate": "^4.6.7" 19 | }, 20 | "devDependencies": { 21 | "@types/jest": "^24.0.9", 22 | "@types/react": "16.8.5", 23 | "@types/react-test-renderer": "16.8.1", 24 | "jest": "^24.1.0", 25 | "lodash.get": "^4.4.2", 26 | "react": "16.12.0", 27 | "react-dom": "16.12.0", 28 | "react-test-renderer": "16.12.0", 29 | "ts-jest": "^24.0.0", 30 | "typescript": "^3.3.3333", 31 | "xstate": "^4.6.7" 32 | }, 33 | "keywords": [ 34 | "xstate", 35 | "react", 36 | "react-hook", 37 | "statecharts", 38 | "state-machines", 39 | "javascript" 40 | ], 41 | "repository": "carloslfu/use-machine", 42 | "author": "@carloslfu", 43 | "license": "MIT" 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useState, useMemo, useEffect, useRef } from "react"; 2 | import { 3 | Machine, 4 | MachineConfig, 5 | EventObject, 6 | AnyEventObject 7 | MachineOptions, 8 | OmniEvent, 9 | State, 10 | StateSchema, 11 | DefaultContext 12 | } from "xstate"; 13 | import { interpret, Interpreter } from "xstate/lib/interpreter"; 14 | 15 | export function useMachine< 16 | TStateSchema extends StateSchema, 17 | TEvent extends AnyEventObject = EventObject, 18 | TContext = DefaultContext 19 | >( 20 | config: MachineConfig, 21 | options: Partial>, 22 | initialContext: TContext 23 | ): TCreateContext { 24 | const machine = useMemo( 25 | () => 26 | Machine(config, options, initialContext), 27 | [] 28 | ); 29 | const [state, setState] = useState(machine.initialState); 30 | const [context, setContext] = useState(initialContext); 31 | const service = useMemo(() => { 32 | const service = interpret(machine); 33 | service.onTransition(setState); 34 | service.onChange(setContext); 35 | service.init(); 36 | return service; 37 | }, [machine]); 38 | 39 | // Stop the service when unmounting. 40 | useEffect(() => { 41 | return () => void service.stop(); 42 | }, [service]); 43 | 44 | return { state, send: service.send, context, service }; 45 | } 46 | 47 | export type TCreateContext< 48 | TContext, 49 | TStateSchema, 50 | TEvent extends AnyEventObject 51 | > = { 52 | state: State; 53 | context: TContext; 54 | send: TSendFn; 55 | service: Interpreter; 56 | }; 57 | 58 | type TSendFn = ( 59 | event: OmniEvent 60 | ) => State; 61 | -------------------------------------------------------------------------------- /src/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TestRenderer from "react-test-renderer"; 3 | import { get as lodashGet } from "lodash"; 4 | 5 | import { useMachine } from "."; 6 | import { MachineConfig } from "xstate"; 7 | 8 | // simple ---------------------------------- 9 | 10 | enum SimpleStateName { 11 | GREEN = "GREEN", 12 | YELLOW = "YELLOW", 13 | RED = "RED" 14 | } 15 | 16 | interface SimpleStateSchema { 17 | states: { 18 | [SimpleStateName.GREEN]: {}; 19 | [SimpleStateName.YELLOW]: {}; 20 | [SimpleStateName.RED]: {}; 21 | }; 22 | } 23 | 24 | enum SimpleAction { 25 | TIMER = "TIMER" 26 | } 27 | 28 | type SimpleEvents = { type: SimpleAction.TIMER }; 29 | 30 | interface SimpleContext { 31 | counter: number; 32 | } 33 | 34 | const SimpleInitialContext: SimpleContext = { 35 | counter: 0 36 | }; 37 | 38 | const simpleMachineConfig: MachineConfig< 39 | SimpleContext, 40 | SimpleStateSchema, 41 | SimpleEvents 42 | > = { 43 | id: "light", 44 | initial: SimpleStateName.GREEN, 45 | states: { 46 | [SimpleStateName.GREEN]: { 47 | on: { TIMER: SimpleStateName.YELLOW } 48 | }, 49 | [SimpleStateName.YELLOW]: { 50 | on: { TIMER: SimpleStateName.RED } 51 | }, 52 | [SimpleStateName.RED]: { 53 | on: { TIMER: SimpleStateName.GREEN } 54 | } 55 | } 56 | }; 57 | 58 | // side effect ---------------------------------- 59 | 60 | enum SimpleStateNameWithSideEffect { 61 | OFF = "OFF", 62 | ON = "ON" 63 | } 64 | 65 | interface SimpleStateSchemaWithSideEffect { 66 | states: { 67 | [SimpleStateNameWithSideEffect.OFF]: {}; 68 | [SimpleStateNameWithSideEffect.ON]: {}; 69 | }; 70 | } 71 | 72 | enum SimpleActionWithSideEffect { 73 | ACTIVATE = "ACTIVATE", 74 | DEACTIVATE = "DEACTIVATE" 75 | } 76 | 77 | enum SimpleCallbackActionWithSideEffect { 78 | SWITCHED = "SWITCHED" 79 | } 80 | 81 | type SimpleEventsWithSideEffect = 82 | | { type: SimpleActionWithSideEffect.ACTIVATE } 83 | | { type: SimpleActionWithSideEffect.DEACTIVATE }; 84 | 85 | interface SimpleContextWithSideEffect { 86 | counter: number; 87 | } 88 | 89 | const simpleInitialContextWithSideEffect: SimpleContextWithSideEffect = { 90 | counter: 0 91 | }; 92 | 93 | const simpleMachineConfigWithSideEffect: MachineConfig< 94 | SimpleContextWithSideEffect, 95 | SimpleStateSchemaWithSideEffect, 96 | SimpleEventsWithSideEffect 97 | > = { 98 | id: "switch", 99 | initial: SimpleStateNameWithSideEffect.OFF, 100 | states: { 101 | [SimpleStateNameWithSideEffect.OFF]: { 102 | on: { 103 | "": { 104 | target: SimpleStateNameWithSideEffect.ON, 105 | actions: [SimpleCallbackActionWithSideEffect.SWITCHED] 106 | } 107 | } 108 | }, 109 | [SimpleStateNameWithSideEffect.ON]: {} 110 | } 111 | }; 112 | 113 | describe("testing useMachine", () => { 114 | it("should successfully render a component with a simple machine and call useEffect", () => { 115 | const spy = jest.spyOn(React, "useEffect"); 116 | 117 | const SimpleTestComponent = () => { 118 | const machine = useMachine(simpleMachineConfig, {}, SimpleInitialContext); 119 | 120 | return
{machine.state.value}
; 121 | }; 122 | 123 | let testComponent = TestRenderer.create(); 124 | const renderedJSON = testComponent.toJSON(); 125 | expect(lodashGet(renderedJSON, "children[0]")).toBe(SimpleStateName.GREEN); 126 | testComponent && testComponent.unmount(); 127 | expect(spy).toHaveBeenCalled(); 128 | spy.mockRestore(); 129 | }); 130 | 131 | it("should successfully re-render an updated component when send is called to change machine state", () => { 132 | const TestComponentWithAction = () => { 133 | const machine = useMachine(simpleMachineConfig, {}, SimpleInitialContext); 134 | 135 | const sendTimer = () => { 136 | machine.send(SimpleAction.TIMER); 137 | }; 138 | 139 | return ( 140 | 141 |
{machine.state.value}
142 | 143 |
144 | ); 145 | }; 146 | 147 | const testComponent = TestRenderer.create(); 148 | // check starting component state 149 | const initialRenderedJSON = testComponent.toJSON(); 150 | expect(lodashGet(initialRenderedJSON, "[0].children[0]")).toBe( 151 | SimpleStateName.GREEN 152 | ); 153 | // click button 154 | TestRenderer.act( 155 | () => 156 | testComponent.root && 157 | testComponent.root.findByType("button").props.onClick() 158 | ); 159 | const afterClickRenderedJSON = testComponent.toJSON(); 160 | // check component state after click 161 | expect(lodashGet(afterClickRenderedJSON, "[0].children[0]")).toBe( 162 | SimpleStateName.YELLOW 163 | ); 164 | }); 165 | 166 | it("should successfully execute a side-effect when one is included in the machine config", () => { 167 | const spy = jest.fn(); 168 | const actionsMock = { 169 | switched: spy 170 | }; 171 | 172 | const TestComponentWithSideEffect = () => { 173 | const machine = useMachine( 174 | simpleMachineConfigWithSideEffect, 175 | { 176 | actions: { 177 | [SimpleCallbackActionWithSideEffect.SWITCHED]: actionsMock.switched 178 | } 179 | }, 180 | simpleInitialContextWithSideEffect 181 | ); 182 | 183 | return null; 184 | }; 185 | 186 | TestRenderer.create(); 187 | expect(spy).toHaveBeenCalled(); 188 | spy.mockRestore(); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/carloslfu/use-machine.svg?branch=master)](https://travis-ci.com/carloslfu/use-machine) 2 | # use-machine 3 | 4 | Use Statecharts in React powered by XState, using the `useMachine` hook. This is a minimalistic implementation (just 30 lines) that integrates React and XState. 5 | 6 | Install it with: `npm i use-machine` 7 | 8 | See --> [the live example here!](https://codesandbox.io/s/5z0820jlyk). 9 | 10 | Let's build something with it: 11 | 12 | ```javascript 13 | import React, { useContext } from 'react' 14 | import ReactDOM from 'react-dom' 15 | import { assign } from 'xstate/lib/actions' 16 | import { useMachine } from 'use-machine' 17 | 18 | const incAction = assign(context => ({ counter: context.counter + 1 })) 19 | 20 | const machineConfig = { 21 | initial: 'Off', 22 | context: { 23 | counter: 0 24 | }, 25 | states: { 26 | Off: { on: { Tick: { target: 'On', actions: [incAction, 'sideEffect'] } } }, 27 | On: { on: { Tick: { target: 'Off', actions: incAction } } } 28 | } 29 | } 30 | 31 | const MachineContext = React.createContext() 32 | 33 | function App() { 34 | const machine = useMachine(machineConfig, { 35 | actions: { 36 | sideEffect: () => console.log('sideEffect') 37 | } 38 | }) 39 | 40 | function sendTick() { 41 | machine.send('Tick') 42 | } 43 | 44 | return ( 45 |
46 | 51 | {machine.state.matches('Off') ? 'Off' : 'On'} 52 | 53 | 54 | Pressed: {machine.context.counter} times 55 | 56 |
57 | 58 |
59 |
60 |
61 | ) 62 | } 63 | 64 | function Child() { 65 | const machine = useContext(MachineContext) 66 | return ( 67 |
68 |
69 | Child state: {machine.state.matches('Off') ? 'Off' : 'On'} 70 |
71 |
Child count: {machine.context.counter}
72 | 73 |
74 | ) 75 | } 76 | 77 | function OtherChild() { 78 | const machine = useContext(MachineContext) 79 | 80 | function sendTick() { 81 | machine.send('Tick') 82 | } 83 | return ( 84 |
85 |
86 | OtherChild state: {machine.state.matches('Off') ? 'Off' : 'On'} 87 |
88 |
OtherChild count: {machine.context.counter}
89 | 90 |
91 | ) 92 | } 93 | 94 | const rootElement = document.getElementById('root') 95 | ReactDOM.render(, rootElement) 96 | ``` 97 | 98 | ## TypeScript 99 | 100 | This library is written in TypeScript, and XState too, so we have excellent support for types. 101 | 102 | Example: 103 | 104 | ```typescript 105 | import React, { useContext } from 'react' 106 | import ReactDOM from 'react-dom' 107 | import { MachineConfig } from 'xstate' 108 | import { assign } from 'xstate/lib/actions' 109 | import { useMachine, TCreateContext } from './use-machine' 110 | 111 | type TContext = { 112 | counter: number 113 | } 114 | 115 | type TSchema = { 116 | states: { 117 | Off: {}, 118 | On: {} 119 | } 120 | } 121 | 122 | type TEvent = { 123 | type: 'Tick' 124 | } 125 | 126 | const incAction = assign(context => ({ counter: context.counter + 1 })) 127 | 128 | const machineConfig: MachineConfig = { 129 | initial: 'Off', 130 | context: { 131 | counter: 0 132 | }, 133 | states: { 134 | Off: { on: { Tick: { target: 'On', actions: [incAction, 'sideEffect'] } } }, 135 | On: { on: { Tick: { target: 'Off', actions: incAction } } } 136 | } 137 | } 138 | 139 | type TMachine = TCreateContext 140 | 141 | const MachineContext = React.createContext({} as TMachine) 142 | 143 | function App() { 144 | const machine = useMachine(machineConfig, { 145 | actions: { 146 | sideEffect: () => console.log('sideEffect') 147 | } 148 | }) 149 | 150 | function sendTick() { 151 | machine.send('Tick') 152 | } 153 | 154 | return ( 155 |
156 | 161 | {machine.state.matches('Off') ? 'Off' : 'On'} 162 | 163 | 164 | Pressed: {machine.context.counter} times 165 | 166 |
167 | 168 |
169 |
170 |
171 | ) 172 | } 173 | 174 | function Child() { 175 | const machine = useContext(MachineContext) 176 | return ( 177 |
178 |
179 | Child state: {machine.state.matches('Off') ? 'Off' : 'On'} 180 |
181 |
Child count: {machine.context.counter}
182 | 183 |
184 | ) 185 | } 186 | 187 | function OtherChild() { 188 | const machine = useContext(MachineContext) 189 | 190 | function sendTick() { 191 | machine.send('Tick') 192 | } 193 | return ( 194 |
195 |
196 | OtherChild state: {machine.state.matches('Off') ? 'Off' : 'On'} 197 |
198 |
OtherChild count: {machine.context.counter}
199 | 200 |
201 | ) 202 | } 203 | 204 | const rootElement = document.getElementById('root') 205 | ReactDOM.render(, rootElement) 206 | ``` 207 | --------------------------------------------------------------------------------