├── example ├── .npmignore ├── index.html ├── tsconfig.json ├── package.json └── index.tsx ├── .gitattributes ├── src ├── index.ts ├── use-store.ts ├── create-action.ts ├── types.ts └── create-store.ts ├── .gitignore ├── test ├── create-action.test.ts └── index.test.ts ├── tsconfig.json ├── package.json ├── LICENSE └── README.md /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-store"; 2 | export * from "./types"; 3 | export * from "./create-action"; 4 | export * from "./use-store"; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | .rts2_cache_system 9 | dist 10 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/use-store.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Observable } from "rxjs"; 3 | 4 | export function useStore( 5 | initialStore: Store, 6 | storeStream: Observable 7 | ) { 8 | const [store, setStore] = useState(initialStore); 9 | 10 | useEffect(() => { 11 | const subscription = storeStream.subscribe(store => setStore(store)); 12 | return () => subscription.unsubscribe(); 13 | }, [storeStream]); 14 | 15 | return store; 16 | } 17 | -------------------------------------------------------------------------------- /src/create-action.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from "rxjs"; 2 | import { Action, Reducer } from "./types"; 3 | 4 | export function createAction( 5 | reducer: Reducer 6 | ): Action { 7 | const subject = new Subject(); 8 | const result: Action = { 9 | act: (ctx: Context) => subject.next(ctx), 10 | stream: subject.asObservable(), 11 | reducer 12 | }; 13 | return result; 14 | } 15 | -------------------------------------------------------------------------------- /test/create-action.test.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from "../src/create-action"; 2 | import { take } from "rxjs/operators"; 3 | 4 | test("createAction", async () => { 5 | type Store = { 6 | state: "enabled"; 7 | ctx: string; 8 | }; 9 | 10 | const reducer = (s: Store, ctx: string) => ({ ...s, ctx }); 11 | const setValue = createAction(reducer); 12 | const resultP = setValue.stream.pipe(take(1)).toPromise(); 13 | setValue.act("Hello!"); 14 | 15 | expect(await resultP).toEqual("Hello!"); 16 | }); 17 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | 3 | export type Reducer = 4 | | ((s: Store) => ReturnStore) 5 | | ((s: Store, a: Context) => ReturnStore); 6 | 7 | export type Action = { 8 | act: (() => void) | ((ctx: Context) => void); 9 | stream: Observable; 10 | reducer: Reducer; 11 | }; 12 | 13 | export type CreateAction any> = T extends ( 14 | s: infer TStore 15 | ) => infer TReturnStore 16 | ? Action 17 | : T extends (s: infer TStore, ctx: infer TContext) => infer TReturnStore 18 | ? Action 19 | : never; 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx-machine", 3 | "version": "2.1.1", 4 | "license": "MIT", 5 | "author": "Marcus Nielsen", 6 | "main": "dist/index.js", 7 | "module": "dist/rx-machine.esm.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "start": "tsdx watch", 14 | "build": "tsdx build", 15 | "test": "tsdx test --env=jsdom", 16 | "lint": "tsdx lint" 17 | }, 18 | "peerDependencies": { 19 | "react": ">=16", 20 | "rxjs": ">=6" 21 | }, 22 | "husky": { 23 | "hooks": { 24 | "pre-commit": "tsdx lint" 25 | } 26 | }, 27 | "devDependencies": { 28 | "@types/jest": "^24.0.23", 29 | "@types/react": "^16.9.16", 30 | "@types/react-dom": "^16.9.4", 31 | "husky": "^3.1.0", 32 | "react": "^16.12.0", 33 | "react-dom": "^16.12.0", 34 | "rxjs": "^6.5.2", 35 | "tsdx": "^0.11.0", 36 | "tslib": "^1.10.0", 37 | "typescript": "^3.7.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Marcus Nielsen 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RxMachine 2 | 3 | _Water meets metal_ 4 | 5 | RxMachine was created to get the stable setup of a `state machine` coupled with the reactiveness from `RxJS`. We also get the benefits of `TypeScript` to make sure that nothing falls outside the state chart. 6 | 7 | ## Example usage 8 | 9 | ```ts 10 | import { createRxm } from "./index"; 11 | import { skip, take } from "rxjs/operators"; 12 | import { createAction } from "./create-action"; 13 | 14 | test("createRxm", async () => { 15 | type Chart = { 16 | started: ["end"]; 17 | ended: ["restart"]; 18 | }; 19 | 20 | type StartedStore = { 21 | state: "started"; 22 | ctx: number; 23 | }; 24 | 25 | type EndedStore = { 26 | state: "ended"; 27 | ctx: number; 28 | }; 29 | 30 | type Store = StartedStore | EndedStore; 31 | 32 | const chart: Chart = { 33 | started: ["end"], 34 | ended: ["restart"] 35 | }; 36 | 37 | const actions = { 38 | end: createAction( 39 | (s: Store, ctx: number): Store => ({ 40 | state: "ended", 41 | ctx 42 | }) 43 | ), 44 | restart: createAction( 45 | (s: Store, ctx: number): Store => ({ 46 | state: "started", 47 | ctx 48 | }) 49 | ) 50 | }; 51 | 52 | const initialStore: Store = { 53 | state: "started", 54 | ctx: 123 55 | } as Store; 56 | 57 | const { store } = createRxm(chart, initialStore, actions); 58 | 59 | const result = store 60 | .pipe( 61 | skip(1), 62 | take(1) 63 | ) 64 | .toPromise(); 65 | 66 | actions.end.trigger(1); 67 | 68 | expect(await result).toEqual({ state: "ended", ctx: 1 }); 69 | }); 70 | ``` 71 | -------------------------------------------------------------------------------- /src/create-store.ts: -------------------------------------------------------------------------------- 1 | import { merge, Observable } from "rxjs"; 2 | import { 3 | map, 4 | startWith, 5 | scan, 6 | shareReplay, 7 | distinctUntilChanged 8 | } from "rxjs/operators"; 9 | import { Action } from "./types"; 10 | export * from "./types"; 11 | 12 | // TODO: Chart keys and Store["state"] should strictly match. 13 | export function createStore< 14 | Chart extends { [k: string]: Array }, 15 | Store extends { state: keyof Chart }, 16 | Actions extends { [k: string]: Action } 17 | >(chart: Chart, initialStore: Store, actions: Actions) { 18 | const chartKeys = Object.keys(chart) as Array; 19 | const allUpdaters = chartKeys.reduce((acc, chartKey) => { 20 | chart[chartKey].forEach(actionKey => { 21 | const updater = actions[actionKey].stream.pipe( 22 | map(ctx => ({ 23 | name: actionKey, 24 | fn: (store: Store) => actions[actionKey].reducer(store, ctx) 25 | })) 26 | ); 27 | if (acc[actionKey] === undefined) { 28 | acc[actionKey] = updater; 29 | } 30 | }); 31 | 32 | return acc; 33 | }, {} as { [k in keyof Actions]: Observable<{ name: keyof Actions; fn: (s: Store) => Store }> }); 34 | 35 | const mergedUpdaters = merge( 36 | ...Object.keys(allUpdaters).map(k => allUpdaters[k]) 37 | ); 38 | 39 | const store: Observable = mergedUpdaters.pipe( 40 | startWith(initialStore), 41 | scan<{ name: keyof Actions; fn: (s: Store) => Store }, Store>( 42 | (store, updater) => 43 | chart[store.state].includes(updater.name) ? updater.fn(store) : store 44 | ), 45 | distinctUntilChanged((s1, s2) => s1 === s2), 46 | shareReplay(1) 47 | ); 48 | 49 | return store; 50 | } 51 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createStore, CreateAction } from "../src/index"; 2 | import { skip, take } from "rxjs/operators"; 3 | 4 | test("createStore", async () => { 5 | type StartedStore = { 6 | state: "started"; 7 | ctx: 0; 8 | }; 9 | 10 | type CountingStore = { 11 | state: "counting"; 12 | ctx: number; 13 | }; 14 | 15 | type EndedStore = { 16 | state: "ended"; 17 | ctx: number; 18 | }; 19 | 20 | type Store = StartedStore | CountingStore | EndedStore; 21 | 22 | type Chart = { 23 | started: ["count"]; 24 | counting: ["count", "end"]; 25 | ended: ["restart"]; 26 | }; 27 | 28 | const chart: Chart = { 29 | started: ["count"], 30 | counting: ["count", "end"], 31 | ended: ["restart"] 32 | }; 33 | 34 | type CountReducer = (s: Store, toAdd: number) => CountingStore; 35 | 36 | const CountReducer: CountReducer = (s, toAdd) => ({ 37 | state: "counting", 38 | ctx: s.ctx + toAdd 39 | }); 40 | 41 | type EndReducer = (s: Store) => EndedStore; 42 | 43 | const endReducer: EndReducer = s => ({ 44 | ...s, 45 | state: "ended" 46 | }); 47 | 48 | type RestartReducer = (s: Store) => StartedStore; 49 | 50 | const restartReducer: RestartReducer = _ => ({ 51 | state: "started", 52 | ctx: 0 53 | }); 54 | 55 | type Actions = { 56 | count: CreateAction; 57 | end: CreateAction; 58 | restart: CreateAction; 59 | }; 60 | 61 | const actions: Actions = { 62 | count: createAction(CountReducer), 63 | end: createAction(endReducer), 64 | restart: createAction(restartReducer) 65 | }; 66 | 67 | const initialStore: StartedStore = { 68 | state: "started", 69 | ctx: 0 70 | }; 71 | 72 | const store = createStore( 73 | chart, 74 | initialStore, 75 | actions 76 | ); 77 | 78 | const result = store.pipe(skip(2), take(1)).toPromise(); 79 | actions.count.act(5); 80 | // Should not trigger a state change when in 'counting' state. 81 | actions.restart.act(); 82 | actions.end.act(); 83 | 84 | expect(await result).toEqual({ state: "ended", ctx: 5 }); 85 | }); 86 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { createStore, createAction, CreateAction, useStore } from '../.'; 5 | 6 | type StartedStore = { 7 | state: 'started'; 8 | ctx: 0; 9 | }; 10 | 11 | type CountingStore = { 12 | state: 'counting'; 13 | ctx: number; 14 | }; 15 | 16 | type EndedStore = { 17 | state: 'ended'; 18 | ctx: number; 19 | }; 20 | 21 | type Store = StartedStore | CountingStore | EndedStore; 22 | 23 | type Chart = { 24 | started: ['count']; 25 | counting: ['count', 'end', 'reset']; 26 | ended: ['reset']; 27 | }; 28 | 29 | const chart: Chart = { 30 | started: ['count'], 31 | counting: ['count', 'end', 'reset'], 32 | ended: ['reset'], 33 | }; 34 | 35 | type CountReducer = (s: Store, toAdd: number) => CountingStore; 36 | 37 | const CountReducer: CountReducer = (s, toAdd) => ({ 38 | state: 'counting', 39 | ctx: s.ctx + toAdd, 40 | }); 41 | 42 | type EndReducer = (s: Store) => EndedStore; 43 | 44 | const endReducer: EndReducer = s => ({ 45 | ...s, 46 | state: 'ended', 47 | }); 48 | 49 | type ResetReducer = () => StartedStore; 50 | 51 | const resetReducer: ResetReducer = () => ({ 52 | state: 'started', 53 | ctx: 0, 54 | }); 55 | 56 | type Actions = { 57 | count: CreateAction; 58 | end: CreateAction; 59 | reset: CreateAction; 60 | }; 61 | 62 | const actions: Actions = { 63 | count: createAction(CountReducer), 64 | end: createAction(endReducer), 65 | reset: createAction(resetReducer), 66 | }; 67 | 68 | const initialStore: CountingStore = { 69 | state: 'counting', 70 | ctx: 0, 71 | }; 72 | 73 | const store = createStore(chart, initialStore, actions); 74 | 75 | (window as any).actions = actions; 76 | 77 | const App = () => { 78 | const data = useStore(initialStore, store); 79 | 80 | return ( 81 | <> 82 |
{JSON.stringify(data, null, 2)}
83 |
91 | 97 | 103 | 104 | 105 |
106 | 107 | ); 108 | }; 109 | 110 | ReactDOM.render(, document.getElementById('root')); 111 | --------------------------------------------------------------------------------