├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── nodejs.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── global.d.ts ├── index.ts ├── package.json ├── rollup.config.ts ├── src └── index.ts ├── test └── index.test.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | # install EditorConfig for VS Code extension 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | indent_size = 2 10 | indent_style = space 11 | insert_final_newline = true 12 | max_line_length = 80 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | max_line_length = 0 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "airbnb", 10 | "prettier", 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "globals": { 16 | "Atomics": "readonly", 17 | "SharedArrayBuffer": "readonly" 18 | }, 19 | "parser": "@typescript-eslint/parser", 20 | "parserOptions": { 21 | "ecmaVersion": 2019, 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "prettier", 26 | "@typescript-eslint" 27 | ], 28 | "rules": { 29 | "no-useless-constructor": 0, 30 | "class-methods-use-this": 0, 31 | "no-underscore-dangle": 0, 32 | "@typescript-eslint/no-non-null-assertion": 0, 33 | "@typescript-eslint/interface-name-prefix": 0, 34 | "@typescript-eslint/explicit-function-return-type": 0, 35 | "max-classes-per-file": 0, 36 | "no-await-in-loop": "off", 37 | "no-restricted-syntax": "off", 38 | "import/no-extraneous-dependencies": [ 39 | "error", 40 | { 41 | "devDependencies": true 42 | } 43 | ], 44 | "import/prefer-default-export": "off", 45 | "prettier/prettier": [ 46 | "error" 47 | ], 48 | "import/extensions": [ 49 | "error", 50 | "ignorePackages", 51 | { 52 | "js": "never", 53 | "ts": "never" 54 | } 55 | ] 56 | }, 57 | "settings": { 58 | "import/resolver": { 59 | "node": { 60 | "extensions": [ 61 | ".js", 62 | ".ts" 63 | ] 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x, 22.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: yarn install, build, and test 21 | run: | 22 | yarn install 23 | yarn build 24 | yarn test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | publish-npm: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '20.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: yarn 18 | - run: yarn build 19 | - run: yarn test 20 | - run: npm publish --provenance 21 | env: 22 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 23 | CI: true 24 | 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mutative 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 | # xstate-mutative 2 | 3 | ![Node CI](https://github.com/mutativejs/xstate-mutative/workflows/Node%20CI/badge.svg) 4 | [![npm](https://img.shields.io/npm/v/xstate-mutative.svg)](https://www.npmjs.com/package/xstate-mutative) 5 | ![license](https://img.shields.io/npm/l/xstate-mutative) 6 | 7 | A faster and more flexible utilities for using [Mutative](https://github.com/unadlib/mutative) with XState 8 | 9 | `xstate-mutative` is more than 10x faster than `@xstate/immer`. [Read more about the performance comparison in Mutative](https://mutative.js.org/docs/getting-started/performance). 10 | 11 | ## Installation 12 | 13 | In order to use the Mutative utilities in XState, you will need to install Mutative and XState as a direct dependency. 14 | 15 | ```bash 16 | npm install xstate mutative xstate-mutative 17 | # Or use any package manager of your choice. 18 | ``` 19 | 20 | ## Usage 21 | 22 | Import the Mutative utilities: 23 | 24 | ```js 25 | import { createMachine, interpret } from 'xstate'; 26 | import { assign, createUpdater } from 'xstate-mutative'; 27 | 28 | const levelUpdater = createUpdater('UPDATE_LEVEL', (ctx, { input }) => { 29 | ctx.level = input; 30 | }); 31 | 32 | const toggleMachine = createMachine({ 33 | id: 'toggle', 34 | context: { 35 | count: 0, 36 | level: 0, 37 | }, 38 | initial: 'inactive', 39 | states: { 40 | inactive: { 41 | on: { 42 | TOGGLE: { 43 | target: 'active', 44 | // Immutably update context the same "mutable" 45 | // way as you would do with Mutative! 46 | actions: assign((ctx) => ctx.count++), 47 | }, 48 | }, 49 | }, 50 | active: { 51 | on: { 52 | TOGGLE: { 53 | target: 'inactive', 54 | }, 55 | // Use the updater for more convenience: 56 | [levelUpdater.type]: { 57 | actions: levelUpdater.action, 58 | }, 59 | }, 60 | }, 61 | }, 62 | }); 63 | 64 | const toggleService = interpret(toggleMachine) 65 | .onTransition((state) => { 66 | console.log(state.context); 67 | }) 68 | .start(); 69 | 70 | toggleService.send({ type: 'TOGGLE' }); 71 | // { count: 1, level: 0 } 72 | 73 | toggleService.send(levelUpdater.update(9)); 74 | // { count: 1, level: 9 } 75 | 76 | toggleService.send({ type: 'TOGGLE' }); 77 | // { count: 2, level: 9 } 78 | 79 | toggleService.send(levelUpdater.update(-100)); 80 | // Notice how the level is not updated in 'inactive' state: 81 | // { count: 2, level: 9 } 82 | ``` 83 | 84 | ### Mutative Options 85 | 86 | - [Strict mode](https://mutative.js.org/docs/advanced-guides/strict-mode) 87 | - [Auto Freeze](https://mutative.js.org/docs/advanced-guides/auto-freeze) 88 | - [Marking data structure](https://mutative.js.org/docs/advanced-guides/mark) 89 | 90 | ## Credits 91 | 92 | `xstate-mutative` is inspired by `@xstate/immer`. 93 | 94 | It uses the same API as `@xstate/immer` but uses Mutative under the hood. The repository is based on the `@xstate/immer` repository. 95 | 96 | ## License 97 | 98 | `xstate-mutative` is [MIT licensed](https://github.com/mutativejs/xstate-mutative/blob/main/LICENSE). 99 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare var __DEV__: boolean 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xstate-mutative", 3 | "version": "1.1.0", 4 | "description": "A faster and more flexible utilities for using Mutative with XState", 5 | "main": "dist/index.cjs.js", 6 | "unpkg": "dist/index.umd.js", 7 | "types": "dist/index.d.ts", 8 | "umd:main": "dist/index.umd.js", 9 | "module": "dist/index.esm.js", 10 | "jsnext:main": "dist/index.esm.js", 11 | "react-native": "dist/index.esm.js", 12 | "typings": "dist/index.d.ts", 13 | "source": "src/index.ts", 14 | "sideEffects": false, 15 | "files": [ 16 | "dist" 17 | ], 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "scripts": { 22 | "test": "jest", 23 | "test:coverage": "jest --coverage && coveralls < coverage/lcov.info", 24 | "clean": "rimraf dist", 25 | "build": "yarn clean && tsc --skipLibCheck && yarn build:prod", 26 | "build:prod": "NODE_ENV=production rollup --config --bundleConfigAsCjs", 27 | "commit": "yarn git-cz" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/mutativejs/xstate-mutative.git" 32 | }, 33 | "author": "unadlib", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/mutativejs/xstate-mutative/issues" 37 | }, 38 | "homepage": "https://github.com/mutativejs/xstate-mutative#readme", 39 | "keywords": [ 40 | "mutative", 41 | "immutable", 42 | "xstate", 43 | "state", 44 | "machine", 45 | "statechart" 46 | ], 47 | "devDependencies": { 48 | "@rollup/plugin-commonjs": "^28.0.0", 49 | "@rollup/plugin-node-resolve": "^15.3.0", 50 | "@rollup/plugin-replace": "^6.0.1", 51 | "@rollup/plugin-terser": "^0.4.4", 52 | "@types/jest": "^29.5.13", 53 | "@types/node": "^22.5.5", 54 | "@typescript-eslint/eslint-plugin": "^8.15.0", 55 | "@typescript-eslint/parser": "^8.15.0", 56 | "commitizen": "^4.3.0", 57 | "coveralls": "^3.1.1", 58 | "eslint": "^8.36.0", 59 | "eslint-config-airbnb": "^19.0.4", 60 | "eslint-config-prettier": "^8.8.0", 61 | "eslint-plugin-import": "^2.27.5", 62 | "eslint-plugin-prettier": "^4.2.1", 63 | "jest": "^29.7.0", 64 | "jest-environment-jsdom": "^29.7.0", 65 | "mutative": "^1.2.0", 66 | "prettier": "^2.8.6", 67 | "rimraf": "^4.4.0", 68 | "rollup": "^4.22.5", 69 | "ts-jest": "^29.2.5", 70 | "ts-node": "^10.9.2", 71 | "tslib": "^2.8.1", 72 | "typedoc": "^0.26.11", 73 | "typedoc-plugin-markdown": "^4.2.10", 74 | "typescript": "^5.8.3", 75 | "xstate": "^5.19.3", 76 | "yargs": "^17.7.1" 77 | }, 78 | "config": { 79 | "commitizen": { 80 | "path": "cz-conventional-changelog" 81 | } 82 | }, 83 | "jest": { 84 | "preset": "ts-jest", 85 | "testEnvironment": "jsdom", 86 | "globals": { 87 | "__DEV__": false 88 | } 89 | }, 90 | "peerDependencies": { 91 | "mutative": "^1.2.0", 92 | "xstate": "^5.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import replace from '@rollup/plugin-replace'; 4 | import terser from '@rollup/plugin-terser'; 5 | import pkg from './package.json'; 6 | 7 | const input = './dist/index.js'; 8 | 9 | export default { 10 | input, 11 | output: [ 12 | { 13 | format: 'cjs', 14 | exports: 'auto', 15 | file: 'dist/index.cjs.js', 16 | sourcemap: true, 17 | }, 18 | { 19 | format: 'es', 20 | file: 'dist/index.esm.js', 21 | sourcemap: true, 22 | }, 23 | { 24 | format: 'umd', 25 | name: pkg.name 26 | .split('-') 27 | .map(([s, ...rest]) => [s.toUpperCase(), ...rest].join('')) 28 | .join(''), 29 | file: pkg.unpkg, 30 | sourcemap: true, 31 | globals: { 32 | mutative: 'Mutative', 33 | xstate: 'Xstate', 34 | react: 'React', 35 | }, 36 | exports: 'named', 37 | }, 38 | ], 39 | plugins: [ 40 | resolve(), 41 | commonjs(), 42 | replace({ 43 | __DEV__: 'false', 44 | preventAssignment: true, 45 | }), 46 | terser(), 47 | ], 48 | external: ['mutative', 'xstate', 'react'], 49 | }; 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | create, 3 | type Draft, 4 | type Options, 5 | type PatchesOptions, 6 | } from 'mutative'; 7 | import { 8 | type AssignArgs, 9 | type EventObject, 10 | type LowInfer, 11 | type MachineContext, 12 | type ParameterizedObject, 13 | type ProvidedActor, 14 | assign as xstateAssign, 15 | } from 'xstate'; 16 | 17 | export type MutativeOptions = Pick< 18 | Options, 19 | Exclude, 'enablePatches'> 20 | >; 21 | 22 | export { mutativeAssign as assign }; 23 | 24 | export type MutativeAssigner< 25 | TContext extends MachineContext, 26 | TExpressionEvent extends EventObject, 27 | TParams extends ParameterizedObject['params'] | undefined, 28 | TEvent extends EventObject, 29 | TActor extends ProvidedActor 30 | > = ( 31 | args: AssignArgs, TExpressionEvent, TEvent, TActor>, 32 | params: TParams 33 | ) => void; 34 | 35 | function mutativeAssign< 36 | TContext extends MachineContext, 37 | TExpressionEvent extends EventObject = EventObject, 38 | TParams extends ParameterizedObject['params'] | undefined = 39 | | ParameterizedObject['params'] 40 | | undefined, 41 | TEvent extends EventObject = EventObject, 42 | TActor extends ProvidedActor = ProvidedActor, 43 | TAutoFreeze extends boolean = false 44 | >( 45 | recipe: MutativeAssigner, 46 | mutativeOptions?: MutativeOptions 47 | ) { 48 | return xstateAssign( 49 | ({ context, ...rest }, params) => { 50 | return create( 51 | context, 52 | (draft) => 53 | void recipe( 54 | { 55 | context: draft, 56 | ...rest, 57 | } as any, 58 | params 59 | ), 60 | mutativeOptions 61 | ) as LowInfer; 62 | } 63 | ); 64 | } 65 | 66 | export interface MutativeUpdateEvent< 67 | TType extends string = string, 68 | TInput = unknown 69 | > { 70 | type: TType; 71 | input: TInput; 72 | } 73 | 74 | export function createUpdater< 75 | TContext extends MachineContext, 76 | TExpressionEvent extends MutativeUpdateEvent, 77 | TEvent extends EventObject, 78 | TActor extends ProvidedActor = ProvidedActor, 79 | TAutoFreeze extends boolean = false 80 | >( 81 | type: TExpressionEvent['type'], 82 | recipe: MutativeAssigner< 83 | TContext, 84 | TExpressionEvent, 85 | ParameterizedObject['params'] | undefined, 86 | TEvent, 87 | TActor 88 | >, 89 | mutativeOptions?: MutativeOptions 90 | ) { 91 | const update = (input: TExpressionEvent['input']): TExpressionEvent => { 92 | return { 93 | type, 94 | input, 95 | } as TExpressionEvent; 96 | }; 97 | 98 | return { 99 | update, 100 | action: mutativeAssign< 101 | TContext, 102 | TExpressionEvent, 103 | ParameterizedObject['params'] | undefined, // TODO: not sure if this is correct 104 | TEvent, 105 | TActor, 106 | TAutoFreeze 107 | >(recipe, mutativeOptions), 108 | type, 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, createActor } from 'xstate'; 2 | import { assign, createUpdater, MutativeUpdateEvent } from '../src'; 3 | 4 | it('should update the context without modifying previous contexts', () => { 5 | const context = { 6 | count: 0, 7 | }; 8 | const countMachine = createMachine({ 9 | types: {} as { context: typeof context }, 10 | id: 'count', 11 | context, 12 | initial: 'active', 13 | states: { 14 | active: { 15 | on: { 16 | INC: { 17 | actions: assign(({ context }) => context.count++), 18 | }, 19 | }, 20 | }, 21 | }, 22 | }); 23 | 24 | const actorRef = createActor(countMachine).start(); 25 | expect(actorRef.getSnapshot().context).toEqual({ count: 0 }); 26 | 27 | actorRef.send({ type: 'INC' }); 28 | expect(actorRef.getSnapshot().context).toEqual({ count: 1 }); 29 | 30 | actorRef.send({ type: 'INC' }); 31 | expect(actorRef.getSnapshot().context).toEqual({ count: 2 }); 32 | }); 33 | 34 | it('should perform multiple updates correctly', () => { 35 | const context = { 36 | count: 0, 37 | }; 38 | const countMachine = createMachine( 39 | { 40 | types: {} as { context: typeof context }, 41 | id: 'count', 42 | context, 43 | initial: 'active', 44 | states: { 45 | active: { 46 | on: { 47 | INC_TWICE: { 48 | actions: ['increment', 'increment'], 49 | }, 50 | }, 51 | }, 52 | }, 53 | }, 54 | { 55 | actions: { 56 | increment: assign(({ context }) => context.count++), 57 | }, 58 | } 59 | ); 60 | 61 | const actorRef = createActor(countMachine).start(); 62 | expect(actorRef.getSnapshot().context).toEqual({ count: 0 }); 63 | 64 | actorRef.send({ type: 'INC_TWICE' }); 65 | expect(actorRef.getSnapshot().context).toEqual({ count: 2 }); 66 | }); 67 | 68 | it('should perform deep updates correctly', () => { 69 | const context = { 70 | foo: { 71 | bar: { 72 | baz: [1, 2, 3], 73 | }, 74 | }, 75 | }; 76 | const countMachine = createMachine( 77 | { 78 | types: {} as { context: typeof context }, 79 | id: 'count', 80 | context, 81 | initial: 'active', 82 | states: { 83 | active: { 84 | on: { 85 | INC_TWICE: { 86 | actions: ['pushBaz', 'pushBaz'], 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | { 93 | actions: { 94 | pushBaz: assign(({ context }) => context.foo.bar.baz.push(0)), 95 | }, 96 | } 97 | ); 98 | 99 | const actorRef = createActor(countMachine).start(); 100 | expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3]); 101 | 102 | actorRef.send({ type: 'INC_TWICE' }); 103 | expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3, 0, 0]); 104 | }); 105 | 106 | it('should create updates', () => { 107 | interface MyContext { 108 | foo: { 109 | bar: { 110 | baz: number[]; 111 | }; 112 | }; 113 | } 114 | const context: MyContext = { 115 | foo: { 116 | bar: { 117 | baz: [1, 2, 3], 118 | }, 119 | }, 120 | }; 121 | 122 | type MyEvents = 123 | | MutativeUpdateEvent<'UPDATE_BAZ', number> 124 | | MutativeUpdateEvent<'OTHER', string>; 125 | 126 | const bazUpdater = createUpdater< 127 | typeof context, 128 | MutativeUpdateEvent<'UPDATE_BAZ', number>, 129 | MyEvents 130 | >('UPDATE_BAZ', ({ context, event }) => { 131 | context.foo.bar.baz.push(event.input); 132 | }); 133 | 134 | const countMachine = createMachine({ 135 | types: { 136 | context: {} as MyContext, 137 | events: {} as MyEvents, 138 | }, 139 | id: 'count', 140 | context, 141 | initial: 'active', 142 | states: { 143 | active: { 144 | on: { 145 | [bazUpdater.type]: { 146 | actions: bazUpdater.action, 147 | }, 148 | }, 149 | }, 150 | }, 151 | }); 152 | 153 | const actorRef = createActor(countMachine).start(); 154 | expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3]); 155 | 156 | actorRef.send(bazUpdater.update(4)); 157 | expect(actorRef.getSnapshot().context.foo.bar.baz).toEqual([1, 2, 3, 4]); 158 | }); 159 | 160 | it('should create updates (form example)', (done) => { 161 | interface FormContext { 162 | name: string; 163 | age: number | undefined; 164 | } 165 | 166 | type NameUpdateEvent = MutativeUpdateEvent<'UPDATE_NAME', string>; 167 | type AgeUpdateEvent = MutativeUpdateEvent<'UPDATE_AGE', number>; 168 | 169 | type FormEvent = 170 | | NameUpdateEvent 171 | | AgeUpdateEvent 172 | | { 173 | type: 'SUBMIT'; 174 | }; 175 | 176 | const nameUpdater = createUpdater( 177 | 'UPDATE_NAME', 178 | ({ context, event }) => { 179 | context.name = event.input; 180 | } 181 | ); 182 | 183 | const ageUpdater = createUpdater( 184 | 'UPDATE_AGE', 185 | ({ context, event }) => { 186 | context.age = event.input; 187 | } 188 | ); 189 | 190 | const formMachine = createMachine({ 191 | types: {} as { context: FormContext; events: FormEvent }, 192 | initial: 'editing', 193 | context: { 194 | name: '', 195 | age: undefined, 196 | }, 197 | states: { 198 | editing: { 199 | on: { 200 | [nameUpdater.type]: { actions: nameUpdater.action }, 201 | [ageUpdater.type]: { actions: ageUpdater.action }, 202 | SUBMIT: 'submitting', 203 | }, 204 | }, 205 | submitting: { 206 | always: { 207 | target: 'success', 208 | guard: ({ context }) => { 209 | return context.name === 'David' && context.age === 0; 210 | }, 211 | }, 212 | }, 213 | success: { 214 | type: 'final', 215 | }, 216 | }, 217 | }); 218 | 219 | const service = createActor(formMachine); 220 | service.subscribe({ 221 | complete: () => { 222 | done(); 223 | }, 224 | }); 225 | service.start(); 226 | 227 | service.send(nameUpdater.update('David')); 228 | service.send(ageUpdater.update(0)); 229 | 230 | service.send({ type: 'SUBMIT' }); 231 | }); 232 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "es2015" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | "lib": [ 8 | "ES2019", 9 | "DOM" 10 | ] /* Specify library files to be included in the compilation. */, 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true, /* Report errors in .js files. */ 13 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | "declaration": true /* Generates corresponding '.d.ts' file. */, 15 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 16 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | "outDir": "./dist" /* Redirect output structure to the directory. */, 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 25 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true /* Enable all strict type-checking options. */, 30 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | 44 | /* Module Resolution Options */ 45 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | "rootDirs": [ 49 | "node_modules/@types" 50 | ] /* List of root folders whose combined content represents the structure of the project at runtime. */, 51 | // "types": [ 52 | // ], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */, 62 | "inlineSources": true /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */, 63 | 64 | /* Experimental Options */ 65 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 66 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 67 | "useDefineForClassFields": false, 68 | 69 | /* Advanced Options */ 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | "include": ["src/*", "global.d.ts"] 73 | } 74 | --------------------------------------------------------------------------------