├── 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 |
--------------------------------------------------------------------------------