├── .gitignore ├── .vscode ├── extensions.json └── tours │ └── Form Example.json ├── CHANGELOG.md ├── .changeset ├── config.json └── README.md ├── tsconfig.json ├── LICENSE ├── package.json ├── src └── index.ts ├── readme.md └── examples └── form.ts /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["vsls-contrib.codetour"] 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # use-fsm-reducer 2 | 3 | ## 2.0.0 4 | ### Major Changes 5 | 6 | - 10dc2e4: Changed config to use "on" attribute for actions, to also support global actions 7 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "restricted", 7 | "baseBranch": "master" 8 | } -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matt Pocock 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": ["dist"], 7 | "scripts": { 8 | "start": "tsdx watch", 9 | "build": "tsdx build", 10 | "test": "tsdx test --passWithNoTests", 11 | "lint": "tsdx lint", 12 | "prepare": "tsdx build", 13 | "publish:patch": "npm version patch && npm run build && npm publish" 14 | }, 15 | "peerDependencies": { 16 | "react": ">=16" 17 | }, 18 | "husky": { 19 | "hooks": { 20 | "pre-commit": "tsdx lint" 21 | } 22 | }, 23 | "prettier": { 24 | "printWidth": 80, 25 | "semi": true, 26 | "singleQuote": true, 27 | "trailingComma": "all" 28 | }, 29 | "name": "use-fsm-reducer", 30 | "author": "Matt Pocock", 31 | "module": "dist/use-fsm-reducer.esm.js", 32 | "repository": "https://github.com/mattpocock/use-fsm-reducer", 33 | "description": "useReducer meets finite state machines", 34 | "devDependencies": { 35 | "@types/jest": "^25.1.3", 36 | "@types/react": "^16.9.23", 37 | "@types/react-dom": "^16.9.5", 38 | "husky": "^4.2.3", 39 | "react": "^16.13.0", 40 | "react-dom": "^16.13.0", 41 | "tsdx": "^0.12.3", 42 | "tslib": "^1.11.1", 43 | "typescript": "^3.8.3" 44 | }, 45 | "dependencies": { 46 | "@changesets/cli": "^2.6.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from 'react'; 2 | 3 | type State = TState & { 4 | effects?: TEffect[]; 5 | }; 6 | 7 | export type UseFsmReducerEffects = { 8 | [K in TEffect['type']]: (params: { 9 | effect: Extract; 10 | dispatch: (action: TAction) => void; 11 | }) => void; 12 | }; 13 | 14 | export type UseFsmReducerStates< 15 | TState extends { type: string }, 16 | TAction extends { type: string }, 17 | TEffect extends { type: string } 18 | > = { 19 | [S in TState['type']]?: { 20 | on?: { 21 | [A in TAction['type']]?: ( 22 | state: State, TEffect>, 23 | action: Extract, 24 | ) => State; 25 | }; 26 | }; 27 | }; 28 | 29 | export interface UseFsmReducerParams< 30 | TState extends { type: string }, 31 | TAction extends { type: string }, 32 | TEffect extends { type: string } 33 | > { 34 | initialState: TState; 35 | states: UseFsmReducerStates; 36 | effects?: UseFsmReducerEffects; 37 | on?: { 38 | [A in TAction['type']]?: ( 39 | state: State, 40 | action: Extract, 41 | ) => State; 42 | }; 43 | runEffectsOnMount?: TEffect[]; 44 | } 45 | 46 | const useFsmReducer = < 47 | TState extends { type: string } = any, 48 | TAction extends { type: string } = any, 49 | TEffect extends { type: string } = any 50 | >({ 51 | initialState, 52 | states, 53 | on, 54 | effects, 55 | runEffectsOnMount, 56 | }: UseFsmReducerParams) => { 57 | const reducer = ( 58 | state: State, 59 | action: TAction, 60 | ): State => { 61 | const actionWithinState = 62 | states?.[state.type as TState['type']]?.on?.[ 63 | action.type as TAction['type'] 64 | ]; 65 | 66 | const globalAction = on?.[action.type as TAction['type']]; 67 | 68 | const actionToRun = actionWithinState || globalAction; 69 | return ( 70 | /** 71 | * If a case exists in this state's 'on' for 72 | * the current state.type, then run it. 73 | * 74 | * If a case exists in the global 'on', 75 | * then run it. 76 | * 77 | * Otherwise, return the current state 78 | */ 79 | actionToRun?.(state as any, action as any) || state 80 | ); 81 | }; 82 | const [state, dispatch] = useReducer(reducer, { 83 | ...initialState, 84 | effects: runEffectsOnMount || [], 85 | } as State); 86 | 87 | useEffect(() => { 88 | state.effects?.forEach(effect => { 89 | /** 90 | * If an effect exists, call it whenever 91 | * the effects change 92 | */ 93 | effects?.[effect.type as TEffect['type']]?.({ 94 | dispatch, 95 | effect: effect as any, 96 | }); 97 | }); 98 | }, [state.effects]); 99 | 100 | return [state, dispatch] as const; 101 | }; 102 | 103 | export default useFsmReducer; 104 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # useFsmReducer 2 | 3 | Heavily inspired by [David Khourshid's article](https://dev.to/davidkpiano/redux-is-half-of-a-pattern-1-2-1hd7). 4 | 5 | `useFsmReducer` is a hook which helps to bring state machine code quality to a common React pattern - `useReducer`. 6 | 7 | It helps you bootstrap a reducer, similar to [redux-toolkit](https://redux-toolkit.js.org/), but also helps extract your effects and help you scale complexity. 8 | 9 | ### Installation 10 | 11 | `npm i use-fsm-reducer` 12 | 13 | # Example Code 14 | 15 | ```ts 16 | import useFsmReducer from 'use-fsm-reducer'; 17 | 18 | /** 19 | * Our state can be in any of these states. 20 | * Note that data is only declared when we're in 21 | * the 'loaded' state. 22 | */ 23 | type State = 24 | | { type: 'pending' } 25 | | { type: 'errored' } 26 | | { type: 'loaded'; data: string }; 27 | 28 | /** 29 | * We declare all possible actions that can flow 30 | * through this reducer 31 | */ 32 | type Action = 33 | | { type: 'reportDataLoaded'; data: string } 34 | | { type: 'reportError' } 35 | | { type: 'retry' }; 36 | 37 | /** 38 | * We declare all effects that this reducer can fire off. 39 | * 40 | * An effect is anything you would put inside a useEffect - an 41 | * impure function that happens as a result of piece 42 | * of state changing, or an action firing. 43 | */ 44 | type Effect = 45 | | { type: 'loadData' } 46 | | { type: 'showErrorMessage'; message: string }; 47 | 48 | /** 49 | * You get back state and dispatch, the API you're 50 | * used to from useReducer 51 | */ 52 | const [state, dispatch] = useFsmReducer({ 53 | /** 54 | * Here, we declare the initial state. Each state 55 | * must have a 'type'. 56 | */ 57 | initialState: { 58 | type: 'pending', 59 | }, 60 | /** 61 | * We can run some initial effects on mount. 62 | */ 63 | runEffectsOnMount: [{ type: 'loadData' }], 64 | states: { 65 | /** 66 | * Each state can handle actions on its own, 67 | * and fire off effects. 68 | */ 69 | pending: { 70 | on: { 71 | reportDataLoaded: (state, action) => { 72 | return { 73 | type: 'loaded', 74 | data: action.data, 75 | }; 76 | }, 77 | reportError: () => { 78 | return { 79 | type: 'errored', 80 | /** 81 | * Return an array of all the effects 82 | * you'd like this state change to trigger 83 | */ 84 | effects: [{ type: 'showErrorMessage', message: 'Oh noooo!' }], 85 | }; 86 | }, 87 | }, 88 | }, 89 | errored: { 90 | on: { 91 | retry: () => { 92 | return { 93 | type: 'pending', 94 | effects: [{ type: 'loadData' }], 95 | }; 96 | }, 97 | }, 98 | }, 99 | }, 100 | effects: { 101 | loadData: ({ dispatch }) => { 102 | /** Fetch some data and fire off more actions */ 103 | fetch('...') 104 | .then(res => res.json()) 105 | .then(data => { 106 | dispatch({ type: 'reportDataLoaded', data: JSON.stringify(data) }); 107 | }) 108 | .catch(() => { 109 | dispatch({ type: 'reportError' }); 110 | }); 111 | }, 112 | showErrorMessage: ({ effect }) => { 113 | console.error(effect.message); 114 | }, 115 | }, 116 | }); 117 | ``` 118 | 119 | > There's another example in [examples/form.ts](./examples/form.ts) 120 | -------------------------------------------------------------------------------- /examples/form.ts: -------------------------------------------------------------------------------- 1 | import useFsmReducer from '../src/index'; 2 | 3 | type State = 4 | | { 5 | type: 'initial'; 6 | email: string; 7 | password: string; 8 | formError?: string; 9 | } 10 | | { 11 | type: 'pending'; 12 | email: string; 13 | password: string; 14 | } 15 | | { type: 'errored'; error: string } 16 | | { type: 'success' }; 17 | 18 | type Action = 19 | | { 20 | type: 'submitForm'; 21 | values: { 22 | email: string; 23 | password: string; 24 | }; 25 | } 26 | | { 27 | type: 'changeValue'; 28 | input: 'email' | 'password'; 29 | value: string; 30 | } 31 | | { 32 | type: 'reportSubmitSuccess'; 33 | } 34 | | { 35 | type: 'clickBackButton'; 36 | } 37 | | { 38 | type: 'reportSubmitError'; 39 | }; 40 | 41 | type Effect = 42 | | { 43 | type: 'submitForm'; 44 | values: { 45 | email: string; 46 | password: string; 47 | }; 48 | } 49 | | { type: 'navigateAwayFromPage' }; 50 | 51 | export const useFormLogic = () => { 52 | return useFsmReducer({ 53 | initialState: { type: 'initial', email: '', password: '' }, 54 | states: { 55 | initial: { 56 | on: { 57 | changeValue: (state, action) => { 58 | return { 59 | ...state, 60 | type: 'initial', 61 | [action.input]: action.value, 62 | formError: '', 63 | }; 64 | }, 65 | submitForm: (state, action) => { 66 | if (!state.password || !state.email) { 67 | return { 68 | ...state, 69 | type: 'initial', 70 | formError: 'You must include all values above.', 71 | }; 72 | } 73 | return { 74 | ...state, 75 | type: 'pending', 76 | effects: [ 77 | { 78 | type: 'submitForm', 79 | values: { email: state.email, password: state.password }, 80 | }, 81 | ], 82 | }; 83 | }, 84 | }, 85 | }, 86 | pending: { 87 | on: { 88 | reportSubmitError: () => { 89 | return { 90 | type: 'errored', 91 | error: 'Oh no, something bad happened.', 92 | }; 93 | }, 94 | reportSubmitSuccess: () => { 95 | return { 96 | type: 'success', 97 | }; 98 | }, 99 | }, 100 | }, 101 | errored: { 102 | on: { 103 | clickBackButton: state => { 104 | return { 105 | ...state, 106 | effects: [{ type: 'navigateAwayFromPage' }], 107 | }; 108 | }, 109 | }, 110 | }, 111 | success: { 112 | on: { 113 | clickBackButton: state => { 114 | return { 115 | ...state, 116 | effects: [{ type: 'navigateAwayFromPage' }], 117 | }; 118 | }, 119 | }, 120 | }, 121 | }, 122 | effects: { 123 | navigateAwayFromPage: () => { 124 | window.location.href = '/'; 125 | }, 126 | submitForm: ({ dispatch, effect }) => { 127 | fetch( 128 | `user-token?email=${effect.values.email}&password=${effect.values.password}`, 129 | ) 130 | .then(() => { 131 | dispatch({ type: 'reportSubmitSuccess' }); 132 | }) 133 | .catch(() => { 134 | dispatch({ type: 'reportSubmitError' }); 135 | }); 136 | }, 137 | }, 138 | }); 139 | }; 140 | -------------------------------------------------------------------------------- /.vscode/tours/Form Example.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Form Example", 3 | "description": "", 4 | "steps": [ 5 | { 6 | "file": "examples/form.ts", 7 | "line": 51, 8 | "description": "This is how we might use useFsmReducer with some form logic.\n\nYou'll learn how to properly type states, actions and effects, and learn how the finite state machine flow helps prevent bugs." 9 | }, 10 | { 11 | "file": "examples/form.ts", 12 | "line": 3, 13 | "description": "If we were coding this in the wild, I would start by declaring all the types that our reducer state can be in.\n\nStates work a little differently in `useFsmReducer`. Instead of declaring a single state:\n\n```ts\ninterface State {\n email: string;\n password: string;\n formError?: string;\n error: string;\n isLoading: boolean;\n hasSucceeded: boolean;\n}\n```\n\nWe describe multiple states, each with a unique 'type':\n\n```ts\ntype State =\n | {\n type: 'initial';\n email: string;\n password: string;\n formError?: string;\n }\n | {\n type: 'pending';\n email: string;\n password: string;\n }\n | { type: 'errored'; error: string }\n | { type: 'success' }\n```" 14 | }, 15 | { 16 | "file": "examples/form.ts", 17 | "line": 18, 18 | "description": "Next, we type out our actions. These will look very similar to traditional reducer actions. Nothing fancy here, just declaring which actions can pass through our reducer." 19 | }, 20 | { 21 | "file": "examples/form.ts", 22 | "line": 41, 23 | "description": "Finally, we type out our Effects.\n\nEffects are side effects that are produced by the reducer. The ones below describe:\n\n1. A `submitForm` effect, which will perform some async action with the API.\n2. A `navigateAwayFromPage` effect, which will set `window.location.href` to `/`\n\nAs you can see, we declare them very similarly to actions. Each of them has a `type`. So, why have Effects at all? Why not just use actions?\n\nHere, I took inspiration from Elm. Elm separates actions from effects in this way too. Actions are pure functions, which only change state and trigger actions. Effects are anything impure, which reacts to environments or performs async logic. Basically - anything you'd put in a useEffect.\n\nModelling effects this way means that you can test the reducer independently from the effects, and switch the effects in and out during implementation." 24 | }, 25 | { 26 | "file": "examples/form.ts", 27 | "line": 52, 28 | "description": "Now that we've typed our State, Action and Effect types, we can pump them in to useFsmReducer." 29 | }, 30 | { 31 | "file": "examples/form.ts", 32 | "line": 53, 33 | "description": "Here, we declare which state we want to start in, and its starting properties. Our form starts empty, with email and password blank." 34 | }, 35 | { 36 | "file": "examples/form.ts", 37 | "line": 56, 38 | "description": "In the initial state, we can change the value of the form. Notice that actions don't have to change state. Even though we're triggering an action, we're actually heading back to the same state, `initial`." 39 | }, 40 | { 41 | "file": "examples/form.ts", 42 | "line": 64, 43 | "description": "When we're in the `initial` state and receive the submitForm action, we check to see if the form is valid. If it's not valid, we go back to the initial state with a formError." 44 | }, 45 | { 46 | "file": "examples/form.ts", 47 | "line": 74, 48 | "description": "But if it is valid, we go to the pending state.\n\nAnd crucially, we fire off our first effect. This is how we call effects from actions - simply return them as an array from the action itself.\n\nNotice that this keeps the action very pure." 49 | }, 50 | { 51 | "file": "src/index.ts", 52 | "line": 69, 53 | "description": "Effects are managed in useFsmReducer via a useEffect call. Every time state.effects changes, it will run through each effect and fire it." 54 | }, 55 | { 56 | "file": "examples/form.ts", 57 | "line": 84, 58 | "description": "Now, we're in the pending state. Note that if we tried to submit the form again, we wouldn't be able to. That action only works when we're in the `initial` state." 59 | }, 60 | { 61 | "file": "examples/form.ts", 62 | "line": 118, 63 | "description": "The submitForm effect is running. If it succeeds, it'll dispatch a reportSubmitSuccess action. If it fails, it'll dispatch a reportSubmitError action.\n\nEffects can do anything - they are a very powerful tool which can handle any async action." 64 | }, 65 | { 66 | "file": "examples/form.ts", 67 | "line": 91, 68 | "description": "Let's say that we received a successful result. All we do now is go to the 'success' state.\n\nAgain, during the success state you can't resubmit the form or change any values - that only works in the 'initial' state." 69 | }, 70 | { 71 | "file": "examples/form.ts", 72 | "line": 106, 73 | "description": "And now we can respond to a 'clickBackButton' action which will take the user back to `/`, via the `navigateAwayFromPage` action." 74 | } 75 | ] 76 | } --------------------------------------------------------------------------------