├── .prettierignore ├── .prettierrc ├── src ├── index.ts ├── types.ts ├── shallowEqual.ts ├── example.ts ├── devtool.ts └── store.ts ├── .travis.yml ├── test ├── test_common_types.ts ├── test.ts ├── tsconfig.json ├── test_enzyme_helper.tsx ├── test_action.ts ├── test_destroy.ts ├── test_example.ts ├── test_stringbased_action_dispatch.ts ├── test_reducer.ts ├── test_devtool.ts ├── test_select.ts ├── test_slicing.ts ├── test_initial_state.ts ├── test_react_connect.tsx └── test_react_storeprovider.tsx ├── react ├── index.tsx ├── actions.tsx ├── state.tsx ├── connect.tsx └── provider.tsx ├── tsconfig.json ├── .gitignore ├── .github └── workflows │ └── main.yml ├── webpack.config.js ├── LICENSE ├── package.json ├── README.md └── CHANGELOG.md /.prettierignore: -------------------------------------------------------------------------------- 1 | src/**/*.d.ts 2 | package.json 3 | CHANGELOG.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": false, 4 | "trailingComma": "all", 5 | "printWidth": 120 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Store } from "./store"; 2 | import { Reducer, StateChangeNotification } from "./types"; 3 | import { shallowEqual } from "./shallowEqual"; 4 | export { Store, Reducer, shallowEqual, StateChangeNotification }; 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 13 5 | - 12 6 | - 10 7 | - 8 8 | 9 | before_script: 10 | - npm install -g yarn 11 | - yarn install 12 | 13 | script: 14 | - yarn run build 15 | - yarn run build-tests 16 | - # yarn run test 17 | - yarn run coveralls 18 | -------------------------------------------------------------------------------- /test/test_common_types.ts: -------------------------------------------------------------------------------- 1 | export interface ExampleState { 2 | counter: number; 3 | message?: string; 4 | bool?: boolean; 5 | someArray?: string[]; 6 | someObject?: object; 7 | } 8 | 9 | export interface SliceState { 10 | foo: string; 11 | slice?: SliceState; 12 | } 13 | 14 | export interface RootState { 15 | slice?: SliceState; 16 | } 17 | 18 | export interface GenericState { 19 | value: any; 20 | } 21 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import "./test_action"; 2 | import "./test_select"; 3 | import "./test_destroy"; 4 | import "./test_devtool"; 5 | import "./test_initial_state"; 6 | import "./test_reducer"; 7 | import "./test_slicing"; 8 | import "./test_stringbased_action_dispatch"; 9 | 10 | import "./test_react_connect"; 11 | import "./test_react_storeprovider"; 12 | 13 | // Uncomment for testing the README.md example or other doc examples 14 | // import "./test_example"; 15 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 4 | "sourceMap": true, 5 | "declaration": false, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "noImplicitAny": false, 10 | "noUnusedLocals": true, 11 | "jsx": "react", 12 | "lib": [ "dom", "es5", "es2015.promise", "es2015.symbol", "es2015.iterable" ] 13 | }, 14 | "include": [ 15 | "../src/*.ts", 16 | "../react/*.ts", 17 | "./**/*.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /react/index.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunction, ActionMap } from "./actions"; 2 | import { connect, ConnectResult } from "./connect"; 3 | import { StoreProvider, StoreSlice, StoreProjection, WithStore, useStore, useStoreState, useStoreSlices } from "./provider"; 4 | 5 | export { 6 | // action 7 | ActionFunction, 8 | ActionMap, 9 | // connect 10 | connect, 11 | ConnectResult, 12 | // provider 13 | useStore, 14 | StoreProvider, 15 | StoreSlice, 16 | StoreProjection, 17 | WithStore, 18 | useStoreState, 19 | useStoreSlices, 20 | }; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | // "outDir": "dist", 5 | "sourceMap": true, 6 | "declaration": true, 7 | "module": "commonjs", 8 | "strict": true, 9 | "importHelpers": false, 10 | "noUnusedLocals": true, 11 | "jsx": "react", 12 | "lib": [ "dom", "es5", "es2015.promise", "es2015.symbol" ] 13 | }, 14 | "include": [ 15 | "src/**/*.ts", 16 | "src/**/*.tsx", 17 | "react/**/*.ts", 18 | "react/**/*.tsx" 19 | ], 20 | "exclude": [ 21 | "test/" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | src/**/*.js 2 | src/**/*.js.map 3 | src/**/*.d.ts 4 | 5 | react/**/*.js 6 | react/**/*.js.map 7 | react/**/*.d.ts 8 | 9 | test/*.d.ts 10 | test/*.js 11 | test/*.js.map 12 | 13 | test/**/*.js 14 | test/**/*.js.map 15 | test/**/*.d.ts 16 | 17 | dist/ 18 | 19 | # Logs 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (http://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Output of 'npm pack' 50 | *.tgz 51 | 52 | # Yarn Integrity file 53 | .yarn-integrity 54 | 55 | # dotenv environment variables file 56 | .env 57 | 58 | -------------------------------------------------------------------------------- /test/test_enzyme_helper.tsx: -------------------------------------------------------------------------------- 1 | import * as Enzyme from "enzyme"; 2 | import * as Adapter from "enzyme-adapter-react-16"; 3 | import { JSDOM } from "jsdom"; 4 | 5 | export function setupJSDomEnv() { 6 | function copyProps(src, target) { 7 | const props = Object.getOwnPropertyNames(src) 8 | .filter(prop => typeof target[prop] === "undefined") 9 | .reduce( 10 | (result, prop) => ({ 11 | ...result, 12 | [prop]: Object.getOwnPropertyDescriptor(src, prop), 13 | }), 14 | {}, 15 | ); 16 | Object.defineProperties(target, props); 17 | } 18 | const jsdom = new JSDOM("", { 19 | url: "http://localhost", 20 | }); 21 | (global as any).window = jsdom.window; 22 | (global as any).document = jsdom.window.document; 23 | (global as any).navigator = { 24 | userAgent: "node.js", 25 | }; 26 | copyProps(jsdom.window, global); 27 | 28 | Enzyme.configure({ adapter: new Adapter() }); 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ $default-branch ] 9 | pull_request: 10 | branches: [ $default-branch ] 11 | workflow_dispatch: 12 | branches: [ $default-branch ] 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [12.x, 14.x, 16.x] 22 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v2 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'yarn' 31 | - run: yarn 32 | - run: yarn build 33 | - run: yarn add react@16 react-dom@16 34 | - run: yarn test 35 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const rxPaths = require('rxjs/_esm5/path-mapping'); 3 | const webpack = require('webpack'); 4 | const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); 5 | 6 | /** 7 | * TODO 8 | * This is just a stub, the resulting output bundle is far from being optimal. We must either use ES2015 modules for 9 | * typescript compilation or use awesome-typescript-plugin for tree shaking to work. Right now ALL of rxjs gets 10 | * bundled. 11 | */ 12 | module.exports = { 13 | entry: "./src/index", 14 | output: { 15 | path: path.resolve(__dirname, "dist/bundles/"), 16 | filename: "reactive-state.umd.js", 17 | library: "ReactiveState", 18 | libraryTarget: "umd" 19 | }, 20 | resolve: { 21 | alias: rxPaths(), 22 | modules: [ 23 | "node_modules" 24 | ], 25 | extensions: [".js"], 26 | }, 27 | module: { 28 | }, 29 | plugins: [ 30 | new webpack.optimize.ModuleConcatenationPlugin(), 31 | new UglifyJSPlugin() 32 | ], 33 | context: __dirname, 34 | target: "web", 35 | mode: "production" 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present, Timo Dörr and contributors 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 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A function which takes a State S and performs a transformation into a new state. The state mutation must be pure. 3 | * @returns A new state of type S 4 | */ 5 | export type StateMutation = (state: S) => S; 6 | 7 | /** 8 | * A reducer takes a state S, a payload P, applies a transformation using the payload to the state and 9 | * returns a new State. Reducers must be pure. 10 | */ 11 | export type Reducer = (state: S, actionPayload: P) => S; 12 | 13 | /** 14 | * Type of a "cleanup" state object that will be set to the slice when the sliceStore gets destroyed 15 | * 16 | * The special string "undefined" means the slice prop should be set to undefined (but the props remains there) 17 | * Using "delete" will remove the whole prop key from the state object (use this to leave no traces) 18 | */ 19 | export type CleanupState = K | null | "undefined" | "delete"; 20 | 21 | export interface ActionDispatch

{ 22 | actionName: string; 23 | actionPayload: P; 24 | } 25 | 26 | export interface StateChangeNotification { 27 | actionName: string | undefined; 28 | actionPayload: any; 29 | newState: S; 30 | } 31 | -------------------------------------------------------------------------------- /test/test_action.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { Subject } from "rxjs"; 4 | import { Reducer, Store } from "../src/index"; 5 | 6 | describe("Action tests", () => { 7 | interface GenericState { 8 | value?: any; 9 | } 10 | class Foo {} 11 | 12 | let store: Store; 13 | let genericAction: Subject; 14 | let genericReducer: Reducer; 15 | 16 | beforeEach(() => { 17 | store = Store.create({ value: undefined }); 18 | genericAction = new Subject(); 19 | genericReducer = (state, payload) => ({ ...state, value: payload }); 20 | store.addReducer(genericAction, genericReducer); 21 | }); 22 | 23 | it("should not throw an error when an action emits a non-plain object", () => { 24 | expect(() => genericAction.next(new Foo())).not.to.throw(); 25 | }); 26 | 27 | // Should be ok for primitive types 28 | it("should not throw an error when an action emits a plain object", () => { 29 | expect(() => genericAction.next({})).not.to.throw(); 30 | }); 31 | 32 | it("should not throw an error when an action emits an array", () => { 33 | expect(() => genericAction.next([])).not.to.throw(); 34 | }); 35 | 36 | it("should not throw an error when an action emits a string", () => { 37 | expect(() => genericAction.next("foobar")).not.to.throw(); 38 | }); 39 | 40 | it("should not throw an error when an action emits a number", () => { 41 | expect(() => genericAction.next(5)).not.to.throw(); 42 | }); 43 | 44 | it("should not throw an error when an action emits a boolean", () => { 45 | expect(() => genericAction.next(false)).not.to.throw(); 46 | }); 47 | 48 | it("should not throw an error when an action emits null", () => { 49 | expect(() => genericAction.next(null)).not.to.throw(); 50 | }); 51 | 52 | it("should not throw an error when an action emits undefined", () => { 53 | expect(() => genericAction.next(undefined)).not.to.throw(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/shallowEqual.ts: -------------------------------------------------------------------------------- 1 | // shallowEqual taken from Facebooks fbjs util and converted to typescript from flow 2 | // since this is used in react 16.x we trust FB that it works and disable code coverage here 3 | 4 | /** 5 | * Copyright (c) 2013-present, Facebook, Inc. 6 | * 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | */ 10 | 11 | const hasOwnProperty = Object.prototype.hasOwnProperty; 12 | 13 | /** 14 | * inlined Object.is polyfill to avoid requiring consumers ship their own 15 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is 16 | */ 17 | /* istanbul ignore next */ 18 | function is(x: any, y: any): boolean { 19 | // SameValue algorithm 20 | if (x === y) { 21 | // Steps 1-5, 7-10 22 | // Steps 6.b-6.e: +0 != -0 23 | // Added the nonzero y check to make Flow happy, but it is redundant 24 | return x !== 0 || y !== 0 || 1 / x === 1 / y; 25 | } else { 26 | // Step 6.a: NaN == NaN 27 | return x !== x && y !== y; 28 | } 29 | } 30 | 31 | /** 32 | * Performs equality by iterating through keys on an object and returning false 33 | * when any key has values which are not strictly equal between the arguments. 34 | * Returns true when the values of all keys are strictly equal. 35 | */ 36 | /* istanbul ignore next */ 37 | export function shallowEqual(objA: any, objB: any): boolean { 38 | if (is(objA, objB)) { 39 | return true; 40 | } 41 | 42 | if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) { 43 | return false; 44 | } 45 | 46 | const keysA = Object.keys(objA); 47 | const keysB = Object.keys(objB); 48 | 49 | if (keysA.length !== keysB.length) { 50 | return false; 51 | } 52 | 53 | // Test for A's keys different from B. 54 | for (let i = 0; i < keysA.length; i++) { 55 | if (!hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { 56 | return false; 57 | } 58 | } 59 | 60 | return true; 61 | } 62 | -------------------------------------------------------------------------------- /react/actions.tsx: -------------------------------------------------------------------------------- 1 | import { Observer, Observable } from "rxjs"; 2 | import { ExtractProps } from "./connect"; 3 | 4 | // Taken from the TypeScript docs, allows to extract all functions of a type 5 | export type FunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]; 6 | export type FunctionProperties = Pick>; 7 | 8 | // Type that can be used to extract the first argument type of a function 9 | export type UnaryFunction = (t: T, ...args: any[]) => any; 10 | 11 | // This will be a function that dispatches actions, but should not return anything 12 | export type ActionFunction = (...args: any[]) => any; 13 | 14 | // An ActionMap is a map with a list of properties, that are functions in the component props, and assigns these properties 15 | // either a ActionFunction or an Observer 16 | export type ActionMap = { 17 | [P in keyof FunctionProperties>]?: 18 | | ActionFunction 19 | | Observer>[P] extends UnaryFunction ? A : never> 20 | }; 21 | 22 | /** 23 | * A map specifying which property on the components state should be populated with 24 | * the value of the map value (=Observable) 25 | * 26 | * @example 27 | * const map = { 28 | * secondsPassed: Observable.interval(1000) 29 | * } 30 | */ 31 | export type UnpackMap = { [P in keyof TComponentState]?: Observable }; 32 | 33 | export function assembleActionProps(actionMap: ActionMap): Partial { 34 | const actionProps: any = {}; 35 | for (let ownProp in actionMap) { 36 | const field = (actionMap as any)[ownProp]; 37 | const observerField = field as Observer; 38 | 39 | if (field === undefined) continue; 40 | 41 | if (typeof field === "function") { 42 | let func = (actionMap as any)[ownProp]; 43 | actionProps[ownProp] = func; 44 | } 45 | // check if its an observable - TODO typeguard? 46 | else if (typeof observerField.next === "function") { 47 | actionProps[ownProp] = (arg1: any, ...args: any[]) => observerField.next(arg1); 48 | } else { 49 | throw new Error( 50 | `unknown property value for property named "${ownProp}" in action map. Expected function or Observer`, 51 | ); 52 | } 53 | } 54 | return actionProps; 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactive-state", 3 | "version": "3.7.1", 4 | "description": "Redux-like state management using RxJS and TypeScript", 5 | "main": "src/index.js", 6 | "files": [ 7 | "src/**/*.js", 8 | "src/**/*.js.map", 9 | "src/**/*.d.ts", 10 | "react/**/*.js", 11 | "react/**/*.js.map", 12 | "react/**/*.d.ts" 13 | ], 14 | "types": "src/index.d.ts", 15 | "sideEffects": false, 16 | "scripts": { 17 | "build": "tsc", 18 | "build-tests": "tsc -p test", 19 | "bundle": "webpack", 20 | "coverage": "node node_modules/.bin/istanbul cover _mocha -- test/test", 21 | "coveralls": "istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage", 22 | "prepublishOnly": "npm run build", 23 | "prettier": "prettier --write {src,test,react}/**/*.{ts,tsx}", 24 | "watch": "tsc -w --preserveWatchOutput", 25 | "watch-tests": "tsc -w -p test --preserveWatchOutput", 26 | "develop": "concurrently \"npm run watch\" \"npm run watch-tests\" ", 27 | "run-tests": "mocha --timeout 10000 test/test.js", 28 | "test": "npm run build-tests && npm run coverage" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/Dynalon/reactive-state.git" 33 | }, 34 | "keywords": [ 35 | "Redux", 36 | "State", 37 | "reactive", 38 | "RxJS", 39 | "store", 40 | "React" 41 | ], 42 | "author": "Timo Dörr", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/Dynalon/reactive-state/issues" 46 | }, 47 | "homepage": "https://github.com/Dynalon/reactive-state", 48 | "devDependencies": { 49 | "@types/chai": "^4.1.7", 50 | "@types/enzyme": "^3.1.13", 51 | "@types/jsdom": "^16.2.5", 52 | "@types/lodash.isobject": "^3.0.3", 53 | "@types/lodash.isplainobject": "^4.0.3", 54 | "@types/mocha": "^8.2.0", 55 | "@types/node": "^14.14.16", 56 | "@types/node-fetch": "^2.5.4", 57 | "@types/react": "17.0.0", 58 | "@types/react-dom": "17.0.0", 59 | "chai": "^4.2.0", 60 | "concurrently": "^5.3.0", 61 | "coveralls": "^3.0.0", 62 | "enzyme": "^3.9.0", 63 | "enzyme-adapter-react-16": "^1.15.5", 64 | "jsdom": "^16.4.0", 65 | "mocha": "^8.2.1", 66 | "mocha-lcov-reporter": "^1.3.0", 67 | "node-fetch": "^2.6.1", 68 | "prettier": "^2.2.1", 69 | "uglifyjs-webpack-plugin": "^2.1.2", 70 | "webpack": "^5.11.0", 71 | "webpack-cli": "^4.2.0", 72 | "istanbul": "^0.4.5" 73 | }, 74 | "dependencies": { 75 | "lodash.isobject": "^3.0.2", 76 | "lodash.isplainobject": "^4.0.6", 77 | "rxjs": "^6.6.3", 78 | "typescript": "^4.1.3" 79 | }, 80 | "optionalDependencies": { 81 | "redux": "^4.0.0" 82 | }, 83 | "peerDependencies": { 84 | "react": "^16.0.0 || ^17.0.1", 85 | "react-dom": "^16.0.0 || ^17.0.1" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/test_destroy.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { Subject } from "rxjs"; 3 | import { Reducer, Store } from "../src/index"; 4 | import { ExampleState } from "./test_common_types"; 5 | 6 | describe("destroy logic", () => { 7 | interface SliceState { 8 | foo: string; 9 | } 10 | interface RootState { 11 | slice?: SliceState; 12 | } 13 | let store: Store; 14 | 15 | beforeEach(() => { 16 | store = Store.create(); 17 | }); 18 | 19 | it("should trigger the onCompleted subscription for the state observable returned by .select() when the store is destroyed", done => { 20 | store.select().subscribe(undefined, undefined, done); 21 | 22 | store.destroy(); 23 | }); 24 | 25 | it("should trigger the onCompleted on the state observable returned by select for any child slice when the parent store is destroyed", done => { 26 | const sliceStore = store.createSlice("slice"); 27 | 28 | sliceStore.select().subscribe(undefined, undefined, done); 29 | 30 | store.destroy(); 31 | }); 32 | 33 | it("should unsubscribe any reducer subscription when the store is destroyed for the root store", done => { 34 | const store = Store.create({ counter: 0 }); 35 | const incrementAction = new Subject(); 36 | const incrementReducer: Reducer = (state, payload) => ({ 37 | ...state, 38 | counter: state.counter + 1, 39 | }); 40 | 41 | const subscription = store.addReducer(incrementAction, incrementReducer); 42 | subscription.add(done); 43 | 44 | store.destroy(); 45 | }); 46 | 47 | it("should unsubscribe any reducer subscription when a sliceStore is destroyed", done => { 48 | const store = Store.create({ counter: 0 }); 49 | const sliceStore = store.createSlice("counter"); 50 | const incrementReducer: Reducer = state => state + 1; 51 | 52 | const subscription = sliceStore.addReducer(new Subject(), incrementReducer); 53 | subscription.add(done); 54 | 55 | sliceStore.destroy(); 56 | }); 57 | 58 | it("should unsubscribe any reducer subscription for a sliceStore when the root store is destroyed", done => { 59 | const store = Store.create({ counter: 0 }); 60 | const sliceStore = store.createSlice("counter"); 61 | const incrementAction = new Subject(); 62 | const incrementReducer: Reducer = state => state + 1; 63 | 64 | const subscription = sliceStore.addReducer(incrementAction, incrementReducer); 65 | subscription.add(done); 66 | 67 | store.destroy(); 68 | }); 69 | 70 | it("should trigger the public destroyed observable when destroyed", done => { 71 | const sliceStore = store.createSlice("slice"); 72 | 73 | sliceStore.destroyed.subscribe(done); 74 | 75 | store.destroy(); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /react/state.tsx: -------------------------------------------------------------------------------- 1 | // THESE HAVE BEEN REMOVED IN v2.0 2 | 3 | // Justification: These helpers encourage usage of observable inside of presentation components. This violates 4 | // the smart/dumb (a.k.a. container/presentational) pattern. To not encourage new users to bad practices, they are not 5 | // exposed anymore. Code is kept for reference and possible future internal use. 6 | 7 | // import { Observable, Subscription } from 'rxjs'; 8 | 9 | // /** 10 | // * A map specifying which property on the components state should be populated with the value of the map value (=observable) 11 | // * 12 | // * @example 13 | // * const map = { 14 | // * secondsPassed: Observable.interval(1000) 15 | // * } 16 | // */ 17 | // export type UnpackMap = { 18 | // [P in keyof TComponentState]?: Observable 19 | // } 20 | 21 | // /* 22 | // * Can be used to bind the last emitted item of multiple observables to a component's internal state. 23 | // * 24 | // * @param component - The component of which we set the internal state 25 | // * @param map - A map for which each key in the map will used as target state property to set the observable item to 26 | // */ 27 | // export function unpackToState( 28 | // component: React.Component, 29 | // map: UnpackMap 30 | // ): Subscription { 31 | // const subscriptions = new Subscription(); 32 | // for (let key in map) { 33 | // const observable = map[key]; 34 | // if (observable === undefined) 35 | // continue; 36 | 37 | // if (typeof observable.subscribe !== "function") { 38 | // throw new Error(`Could not map non-observable for property ${key}`) 39 | // } 40 | // subscriptions.add(bindToState(component, observable!, key)); 41 | // } 42 | // return subscriptions; 43 | // } 44 | 45 | // export function mapToState( 46 | // component: React.Component, 47 | // source: Observable, 48 | // setStateFn: (item: T, prevState: TComponentState, props: TComponentProps) => TComponentState 49 | // ): Subscription { 50 | 51 | // return source.subscribe((item: T) => { 52 | // component.setState((prevState: TComponentState, props: TComponentProps) => { 53 | // return setStateFn(item, prevState, props); 54 | // }) 55 | // }) 56 | // } 57 | 58 | // /** 59 | // * Sets the emitted values of an observable to a components state using setState(). The 60 | // * subscription to the source observable is automatically unsubscribed when the component 61 | // * unmounts. 62 | // */ 63 | // export function bindToState( 64 | // component: React.Component , 65 | // source: Observable, 66 | // stateKey: keyof TState 67 | // ): Subscription { 68 | // const subscription = source.subscribe((item: T) => { 69 | // const patch = { [stateKey]: item }; 70 | // // TODO eliminate any 71 | // component.setState((prevState: any) => ({ ...prevState, ...patch })) 72 | // }) 73 | 74 | // // unsubscribe then the component is unmounted 75 | // const originalUnmount = component.componentWillUnmount; 76 | // component.componentWillUnmount = function() { 77 | // subscription.unsubscribe(); 78 | // if (originalUnmount) { 79 | // originalUnmount.call(component); 80 | // } 81 | // }.bind(component); 82 | 83 | // return subscription; 84 | // } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Dynalon/reactive-state.svg?branch=master)](https://travis-ci.org/Dynalon/reactive-state) 2 | [![npm version](https://badge.fury.io/js/reactive-state.svg)](https://badge.fury.io/js/reactive-state) 3 | ![code coverage](https://coveralls.io/repos/Dynalon/reactive-state/badge.svg?branch=master&service=github) 4 | 5 | Reactive State 6 | ==== 7 | 8 | A typed, wrist-friendly state container aimed as an alternative to Redux when using RxJS. Written with RxJS in TypeScript but perfectly usable from plain JavaScript. Originally inspired by the blog posting from [Michael Zalecki](http://michalzalecki.com/use-rxjs-with-react/) but heavily modified and extended since. 9 | 10 | Features 11 | ---- 12 | 13 | * type-safe actions: no boilerplate code, no mandatory string constants, and not a single switch statement 14 | * Actions are just Observables, so are Subjects. Just call `.next()` to dispatch an action. 15 | * dynamically add and remove reducers during runtime 16 | * no need for async middlewares such as redux-thunk/redux-saga; actions are Observables and can be composed and transformed async using RxJS operators 17 | * no need for selector libraries like MobX or Reselect, RxJS already ships it 18 | * single, application-wide Store concept as in Redux. Possibility to create slices/substates for decoupling (easier reducer composition and state separation by module) 19 | * Strictly typed to find errors during compile time 20 | * Heavily unit tested, 100+ tests for ~250 lines of code 21 | * React bridge (like `react-redux`) included, though using React is not mandatory 22 | * Support for React-Devtool Extension 23 | 24 | Installation 25 | ---- 26 | ``` 27 | npm install reactive-state 28 | ``` 29 | 30 | Documentation 31 | ---- 32 | 33 | * [Wiki](https://github.com/Dynalon/reactive-state/wiki) 34 | * [Demo App with annotated source](https://github.com/Dynalon/reactive-state-react-example) (includes react bridge examples) 35 | 36 | Additionally, there is a small [example.ts file](https://github.com/Dynalon/reactive-state/blob/master/src/example.ts) and see also see the included [unit tests](https://github.com/Dynalon/reactive-state/tree/master/test) as well. 37 | 38 | 39 | Example Usage 40 | ---- 41 | 42 | ```typescript 43 | import { Store } from "reactive-state"; 44 | import { Subject } from "rxjs"; 45 | import { take } from "rxjs/operators"; 46 | 47 | // The state for our example app 48 | interface AppState { 49 | counter: number; 50 | } 51 | 52 | const initialState: AppState = { counter: 0 } 53 | 54 | const store = Store.create(initialState); 55 | 56 | // The .watch() function returns an Observable that emits the selected state change, so we can subscribe to it 57 | store.watch().subscribe(newState => console.log("STATE:", JSON.stringify(newState))); 58 | 59 | // the watch() observable always caches the last emitted state, so we will immediately print our inital state: 60 | // [CONSOLE.LOG]: STATE: {"counter":0} 61 | 62 | // use a RxJS Subjects as an action 63 | const incrementAction = new Subject(); 64 | 65 | // A reducer is a function that takes a state and an optional payload, and returns a new state 66 | function incrementReducer(state, payload) { 67 | return { ...state, counter: state.counter + payload }; 68 | }; 69 | 70 | store.addReducer(incrementAction, incrementReducer); 71 | 72 | // lets dispatch some actions 73 | 74 | incrementAction.next(1); 75 | // [CONSOLE.LOG]: STATE: {"counter":1} 76 | incrementAction.next(1); 77 | // [CONSOLE.LOG]: STATE: {"counter":2} 78 | 79 | // async actions? No problem, no need for a "middleware", just use RxJS 80 | interval(1000).pipe(take(3)).subscribe(() => incrementAction.next(1)); 81 | // 82 | // [CONSOLE.LOG]: STATE: {"counter":3} 83 | // 84 | // [CONSOLE.LOG]: STATE: {"counter":4} 85 | // 86 | // [CONSOLE.LOG]: STATE: {"counter":5} 87 | ``` 88 | 89 | License 90 | ---- 91 | 92 | MIT. 93 | -------------------------------------------------------------------------------- /src/example.ts: -------------------------------------------------------------------------------- 1 | import { Store, Reducer } from "./index"; 2 | import { Subject } from "rxjs"; 3 | 4 | // you can run this example with node: "node dist/example" from the project root. 5 | 6 | // The main (root) state for our example app 7 | interface AppState { 8 | counter: number; 9 | 10 | // Example of a typed substate/slice 11 | todoState: TodoState; 12 | } 13 | 14 | interface Todo { 15 | id: number; 16 | title: string; 17 | done: boolean; 18 | } 19 | 20 | interface TodoState { 21 | todos: Todo[]; 22 | } 23 | 24 | const initialState: AppState = { 25 | counter: 0, 26 | todoState: { 27 | todos: [{ id: 1, title: "Homework", done: false }, { id: 2, title: "Walk dog", done: false }], 28 | }, 29 | }; 30 | 31 | // create our root store 32 | const store = Store.create(initialState); 33 | 34 | // Log all state changes using the .select() function 35 | store.select().subscribe(newState => console.log(JSON.stringify(newState))); 36 | 37 | // Any Observable can be an action - we use a Subject here 38 | const incrementAction = new Subject(); 39 | const incrementReducer: Reducer = (state: number, payload: void) => state + 1; 40 | 41 | const decrementAction = new Subject(); 42 | const decrementReducer: Reducer = (state: number, payload: void) => state - 1; 43 | 44 | // while it looks like a magic string, it is NOT: 'counter' is of type "keyof AppState"; so putting 45 | // any non-property name of AppState here is actually a compilation error! 46 | const counterStore = store.createSlice("counter"); 47 | 48 | counterStore.addReducer(incrementAction, incrementReducer); 49 | counterStore.addReducer(decrementAction, decrementReducer); 50 | 51 | // dispatch some actions - we just call .next() (here with no payload) 52 | incrementAction.next(); 53 | incrementAction.next(); 54 | decrementAction.next(); 55 | 56 | // wire up ToDos 57 | const deleteToDoAction = new Subject(); 58 | const deleteToDoReducer: Reducer = (state, payload) => { 59 | const filteredTodos = state.todos.filter(todo => todo.id != payload); 60 | return { ...state, todos: filteredTodos }; 61 | }; 62 | 63 | const markTodoAsDoneAction = new Subject(); 64 | // This reducer purposely is more complicated than it needs to be, but shows how you would do it in Redux 65 | // you will find a little easier solution using a more specific slice below 66 | 67 | const markTodoAsDoneReducer: Reducer = (state: TodoState, payload: number) => { 68 | const todos = state.todos.map(todo => { 69 | if (todo.id != payload) return todo; 70 | return { 71 | ...todo, 72 | done: true, 73 | }; 74 | }); 75 | return { ...state, todos }; 76 | }; 77 | 78 | const todoStore = store.createSlice("todoState"); 79 | todoStore.addReducer(deleteToDoAction, deleteToDoReducer); 80 | const reducerSubscription = todoStore.addReducer(markTodoAsDoneAction, markTodoAsDoneReducer); 81 | 82 | markTodoAsDoneAction.next(1); 83 | deleteToDoAction.next(1); 84 | 85 | // now, using .createSlice() can be used to select the todos array directly and our reducer becomes less complex 86 | 87 | // first, disable the previous complex reducer 88 | reducerSubscription.unsubscribe(); 89 | 90 | // create a slice pointing directly to the todos array 91 | const todosArraySlice = store.createSlice("todoState").createSlice("todos"); 92 | 93 | // create simpler reducer 94 | const markTodoAsDoneSimpleReducer: Reducer = (state: Todo[], payload: number) => { 95 | return state.map(todo => { 96 | if (todo.id != payload) return todo; 97 | return { 98 | ...todo, 99 | done: true, 100 | }; 101 | }); 102 | }; 103 | 104 | todosArraySlice.addReducer(markTodoAsDoneAction, markTodoAsDoneSimpleReducer); 105 | markTodoAsDoneAction.next(2); 106 | deleteToDoAction.next(2); 107 | -------------------------------------------------------------------------------- /test/test_example.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { interval, Subject, zip } from "rxjs"; 3 | import { map, take } from "rxjs/operators"; 4 | import { Reducer, Store } from "../src/index"; 5 | 6 | // make sure the example in the README.md actually works and compiles 7 | // use this test as playground 8 | export function testExample() { 9 | // The state for our example app 10 | interface AppState { 11 | counter: number; 12 | } 13 | 14 | const initialState: AppState = { counter: 0 }; 15 | 16 | const store = Store.create(initialState); 17 | 18 | // The .select() function returns an Observable that emits every state change, so we can subscribe to it 19 | store.select().subscribe(newState => console.log("STATE:", JSON.stringify(newState))); 20 | 21 | // the select() observable always caches the last emitted state, so we will immediately print our inital state: 22 | // [CONSOLE.LOG]: STATE: {"counter":0} 23 | 24 | // use a RxJS Subjects as an action 25 | const incrementAction = new Subject(); 26 | 27 | // A reducer is a function that takes a state and an optional payload, and returns a new state 28 | function incrementReducer(state, payload) { 29 | return { ...state, counter: state.counter + payload }; 30 | } 31 | 32 | store.addReducer(incrementAction, incrementReducer); 33 | 34 | // lets dispatch some actions 35 | 36 | incrementAction.next(1); 37 | // [CONSOLE.LOG]: STATE: {"counter":1} 38 | incrementAction.next(1); 39 | // [CONSOLE.LOG]: STATE: {"counter":2} 40 | 41 | // async actions? No problem, no need for a "middleware", just use RxJS 42 | interval(1000) 43 | .pipe(take(3)) 44 | .subscribe(() => incrementAction.next(1)); 45 | // 46 | // [CONSOLE.LOG]: STATE: {"counter":3} 47 | // 48 | // [CONSOLE.LOG]: STATE: {"counter":4} 49 | // 50 | // [CONSOLE.LOG]: STATE: {"counter":5} 51 | } 52 | 53 | describe.skip("example", () => { 54 | it("should run the example", done => { 55 | testExample(); 56 | setTimeout(() => { 57 | done(); 58 | }, 10000); 59 | }); 60 | }); 61 | 62 | export function testComputedValuesExample() { 63 | interface Todo { 64 | id: number; 65 | title: string; 66 | done: boolean; 67 | } 68 | 69 | interface TodoState { 70 | todos: Todo[]; 71 | } 72 | 73 | const store: Store = Store.create({ 74 | todos: [ 75 | { 76 | id: 0, 77 | title: "Walk the dog", 78 | done: false, 79 | }, 80 | { 81 | id: 1, 82 | title: "Homework", 83 | done: false, 84 | }, 85 | { 86 | id: 2, 87 | title: "Do laundry", 88 | done: false, 89 | }, 90 | ], 91 | }); 92 | 93 | const markTodoAsDone = new Subject(); 94 | const markTodoAsDoneReducer: Reducer = (state, id) => { 95 | let todo = state.filter(t => t.id === id)[0]; 96 | todo = { ...todo, done: true }; 97 | return [...state.filter(t => t.id !== id), todo]; 98 | }; 99 | 100 | const todoStore = store.createSlice("todos"); 101 | todoStore.addReducer(markTodoAsDone, markTodoAsDoneReducer); 102 | 103 | const todos = todoStore.select(); 104 | 105 | // create an auto computed observables using RxJS basic operators 106 | 107 | const openTodos = todos.pipe(map(todos => todos.filter(t => t.done == false).length)); 108 | const completedTodos = todos.pipe(map(todos => todos.filter(t => t.done == true).length)); 109 | 110 | // whenever the number of open or completed todos changes, log a message 111 | zip(openTodos, completedTodos).subscribe(([open, completed]) => 112 | console.log(`I have ${open} open todos and ${completed} completed todos`), 113 | ); 114 | 115 | markTodoAsDone.next(0); 116 | markTodoAsDone.next(1); 117 | markTodoAsDone.next(2); 118 | } 119 | 120 | // testComputedValuesExample(); 121 | -------------------------------------------------------------------------------- /src/devtool.ts: -------------------------------------------------------------------------------- 1 | import { createStore, StoreEnhancer, compose, Action as ReduxAction } from "redux"; 2 | import { Store } from "./store"; 3 | import { Subject } from "rxjs"; 4 | import { take } from "rxjs/operators"; 5 | import { StateChangeNotification } from "./types"; 6 | 7 | /* istanbul ignore next */ 8 | 9 | // symbols only for debugging and devtools 10 | export { StateChangeNotification } from "./types"; 11 | 12 | export function enableDevTool(store: Store) { 13 | console.warn( 14 | "enableDevTool requires the browser extension. Note: the 'skip action' feature is not supported (but 'jump' works as expected')", 15 | ); 16 | 17 | if (typeof window === "undefined") { 18 | // nodejs deployments? 19 | return; 20 | } 21 | 22 | const extension = (window as any)["__REDUX_DEVTOOLS_EXTENSION__"] || (window as any)["devToolsExtension"]; 23 | if (!extension) { 24 | console.warn("devToolsExtension not found in window (extension not installed?). Could not enable devTool"); 25 | return; 26 | } 27 | 28 | const devtoolExtension: StoreEnhancer = extension(); 29 | const reduxToReactiveSync = new Subject(); 30 | const reactiveStateUpdate = new Subject(); 31 | 32 | store 33 | .select() 34 | .pipe(take(1)) 35 | .subscribe(initialState => { 36 | const enhancer: StoreEnhancer = next => { 37 | return (reducer, preloadedState) => { 38 | // run any other store enhancers 39 | const reduxStore = next(reducer, initialState as any); 40 | 41 | // write back the state from DevTools/Redux to our ReactiveState 42 | reduxStore.subscribe(() => { 43 | // const reduxState = reduxStore.getState(); 44 | // console.info("RDX UPD STATE: ", reduxState) 45 | // console.info("JUMP/SKIP not supported, do not use or you get undefined behaviour!") 46 | // reduxToReactiveSync.next(reduxState); 47 | }); 48 | 49 | reactiveStateUpdate.subscribe((p: any) => { 50 | // console.info("RDX DISP", p) 51 | reduxStore.dispatch({ type: p.actionName, payload: p.payload, state: p.state } as any); 52 | }); 53 | return reduxStore; 54 | }; 55 | }; 56 | 57 | // TODO: State should be type S, but TS does not yet support it 58 | // maybe after TS 2.7: https://github.com/Microsoft/TypeScript/issues/10727 59 | const reduxReducer = (state: any, action: ReduxAction & { state: any }) => { 60 | // TODO: "skip" in devtools does not work here. In plain redux, we could call our reducers with the state 61 | // and the action payload of the (replayed-) action. But we can"t to it with our store, as even if we 62 | // could reset it to a state and replay the action, the operation is async. But we must return a state 63 | // here in a sync manner... :( 64 | 65 | if (action.type === "@@INIT") { 66 | // redux internal action 67 | return { ...state }; 68 | } 69 | // What we actually do is instead of returning reduce(state, action) we return the result-state we have 70 | // attached to action, that is kept in the log 71 | return { ...action.state }; 72 | }; 73 | 74 | createStore( 75 | reduxReducer as any, 76 | initialState, 77 | compose( 78 | enhancer, 79 | devtoolExtension, 80 | ), 81 | ); 82 | }); 83 | 84 | store.stateChangedNotification.subscribe((notification: StateChangeNotification) => { 85 | const { actionName, actionPayload, newState } = notification; 86 | if (actionName !== "__INTERNAL_SYNC") 87 | reactiveStateUpdate.next({ actionName: actionName || "UNNAMED", payload: actionPayload, state: newState }); 88 | }); 89 | 90 | const syncReducer = (state: S, payload: any) => { 91 | return { ...payload }; 92 | }; 93 | store.addReducer(reduxToReactiveSync, syncReducer, "__INTERNAL_SYNC"); 94 | } 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v3.5 2 | * Fix a typing bug that made it impossible to use hooks-based (functional) Components with react bridge 3 | * Bump dependencies to latest version 4 | * Due to a change in @types/react we can no longer use ActionMap - use ActionMap instead 5 | 6 | v3.4 7 | 8 | * Make current state of a store available in `store.currentState` just as in a BehaviorSubject. This helps in synchronous code (i.e. react state init). 9 | 10 | v3.3 11 | 12 | * add useStore() hook to consume a store provided via through new Hooks API 13 | 14 | v3.2 15 | 16 | * react bridge: Removed `mapStatetoProps` function and use much simpler `props` which is just an Observable emitting 17 | the props of the connected component 18 | * react bridge: Pass down an Observable of the input props given to a connected component 19 | * react bridge: Remove `cleanup` return property in connect: subscribe to the store.destroy observable instead which 20 | gets called upon unmount 21 | * react bridge: The `store` argument passed as the `ConnectCallback` in the `connect()` function now calls .clone() 22 | on the store internally and automatically calls .destroy() on the clone when the component is unmount. That way we 23 | don't need custom cleanup logic inside `connect()`. 24 | 25 | v3.0 26 | 27 | * Removed `Action` type (use Subject and specify a name as 3rd argument to .addReducer() instead) 28 | * New way of creating slices: Projections. Use .createProjection() to map any properties from a state to another (sliced) state. 29 | * Add .clone() method to Store which is like a slice without any transformation but uses the same state object. 30 | Useful to scope .select()/.watch() subscriptions, as .destroy() will end all subscriptions of the clone but 31 | will not affect the original. 32 | * We do not create immutable copies for initial states anymore but re-use the object passed in 33 | as initial state. Create immutable copies yourself if needed before creating a store. 34 | * Remove fully bundled UMD module from published package, you should use your own bundler like webpack. 35 | * Requires React >=16.4 for react bridge 36 | * Switch to reacts new context API for react bridge StoreProvider 37 | * Drop deprecated lifecycle hooks to be ready for React v17 38 | * Drop `undefined` as a valid return type for the `ConnectCallback` (you can use empty object `{}` though) 39 | 40 | v2.0.0 41 | 42 | * fully RxJS 6 based (without need for rxjs-compat) 43 | * store.select() now emits on every state change, no matter if the result in the selection function is affected by 44 | the changes (disregards shallow identity) 45 | * introduce store.watch() that works as .select(), but performs a shallow equal check on each state change, not emitting 46 | a state if it is shallow-equal to the previous state 47 | * react bridge: complete change of react connect() API: usage of Components as wrapper now discouraged, everything can 48 | be wired inside a single function now passed to connect() 49 | * react bridge: very strict typing of MapStateToProps and ActionMap types using TypeScript 2.8 conditional types 50 | * react bridge: is now a first-class citizen: Enzyme based tests with full DOM rendering implemented; react bridge tests 51 | contribute to overall code coverage 52 | * react bridge: Use to provide a store instance via React's context API 53 | * react bridge: Introduce "keyOfState"}> to create store slices in a declarative way 54 | 55 | v1.0.0 56 | 57 | * Fix type-inference for .createSlice() - this breaks existing code (just remove the type argument from 58 | .createSlice() to fix). Contributed by Sebastian Nemeth. 59 | 60 | v0.5.0 61 | * React bridge now considered mature and can be imported from 'reactive-state/react' 62 | * Do not overwrite any initialstate on a slice if that prop is not undefined 63 | * Breaking change: Do not clone initialState/cleanupState for stores/slices. This means that whatever you pass 64 | as initial state object can be modified by the store, and modifications will be visisble to whoever uses that 65 | instance. The non-clone behaviour is no coherent with Redux behaviour and allows us to drop a cloneDeep() 66 | implementation which save a lot of kilobytes in the output bundle. 67 | * Better devtool integration with notifyStateChange observable on the store 68 | 69 | 70 | v0.4.0 71 | * Use lettable operators from RxJS 5.5 72 | * Change API for devtool 73 | 74 | v0.2.2 75 | 76 | * Fixed tslib only being a dev dependency, although it is needed as runtime dep 77 | when included from another project 78 | 79 | v0.2.1 80 | 81 | * Fixed .select() not correctly infering the type when given no arguments 82 | * Fixed behaviour of special cleanup state string "undefined" which would delete they key - 83 | This will now set the key on the state to real undefined (non-string) upon cleanup 84 | * Added a "delete" special cleanup state string that will behave as "undefined" as before 85 | (remove the key from the parent state alltogether) 86 | * Started a changelog. 87 | -------------------------------------------------------------------------------- /test/test_stringbased_action_dispatch.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { expect } from "chai"; 3 | import { Store, Reducer } from "../src/index"; 4 | import { ExampleState } from "./test_common_types"; 5 | import { Subject } from "rxjs"; 6 | 7 | describe("String based action dispatch", () => { 8 | let store: Store; 9 | let incrementReducer: Reducer; 10 | const INCREMENT_ACTION = "INCREMENT_ACTION"; 11 | 12 | beforeEach(() => { 13 | const initialState = { 14 | counter: 0, 15 | }; 16 | store = Store.create(initialState); 17 | incrementReducer = (state, payload = 1) => ({ ...state, counter: state.counter + payload }); 18 | }); 19 | 20 | afterEach(() => { 21 | store.destroy(); 22 | }); 23 | 24 | describe(" unsliced ", () => { 25 | it("should be possible to add an action string identifier instead of an observable", () => { 26 | expect(() => { 27 | store.addReducer(INCREMENT_ACTION, incrementReducer); 28 | }).not.to.throw(); 29 | }); 30 | 31 | it("should not be possible to add an action string identifier and an additional action name at the same time", () => { 32 | expect(() => { 33 | store.addReducer(INCREMENT_ACTION, incrementReducer, "FOO"); 34 | }).to.throw(); 35 | }); 36 | 37 | it("should not be possible to add an empty string as action name", () => { 38 | expect(() => { 39 | store.addReducer("", incrementReducer); 40 | }).to.throw(); 41 | }); 42 | 43 | it("should be possible to add an action by string and trigger a manual dispatch on it", done => { 44 | store.addReducer(INCREMENT_ACTION, incrementReducer); 45 | store.dispatch(INCREMENT_ACTION, 1); 46 | store.select().subscribe(state => { 47 | expect(state.counter).to.equal(1); 48 | done(); 49 | }); 50 | }); 51 | 52 | it("should be possible to add an action as unnamed observable with additional action identifier and trigger a manual dispatch on it", done => { 53 | const incrementAction = new Subject(); 54 | store.addReducer(incrementAction, incrementReducer, INCREMENT_ACTION); 55 | store.dispatch(INCREMENT_ACTION, 1); 56 | store.select().subscribe(state => { 57 | expect(state.counter).to.equal(1); 58 | done(); 59 | }); 60 | }); 61 | 62 | it("should be possible to add an action completely unnamed and nothing should happend when dispatching undefined", done => { 63 | const incrementAction = new Subject(); 64 | store.addReducer(incrementAction, incrementReducer); 65 | store.dispatch(INCREMENT_ACTION, undefined); 66 | store.select().subscribe(state => { 67 | expect(state.counter).to.equal(0); 68 | done(); 69 | }); 70 | }); 71 | }); 72 | 73 | describe(" sliced ", () => { 74 | let sliceStore: Store; 75 | let sliceIncrementReducer: Reducer; 76 | 77 | beforeEach(() => { 78 | sliceStore = store.createSlice("counter"); 79 | sliceIncrementReducer = (state, payload) => state + payload; 80 | }); 81 | 82 | afterEach(() => { 83 | sliceStore.destroy(); 84 | }); 85 | 86 | it("should be possible to add an action by string and trigger a manual dispatch on it, and slice and root receive the change", done => { 87 | sliceStore.addReducer(INCREMENT_ACTION, sliceIncrementReducer); 88 | sliceStore.dispatch(INCREMENT_ACTION, 1); 89 | store.select().subscribe(state => { 90 | expect(state.counter).to.equal(1); 91 | sliceStore.select().subscribe(counter => { 92 | expect(counter).to.equal(1); 93 | done(); 94 | }); 95 | }); 96 | }); 97 | 98 | it("should be possible to add an action by string on a slice and dispatch it on the root store", done => { 99 | sliceStore.addReducer(INCREMENT_ACTION, sliceIncrementReducer); 100 | store.dispatch(INCREMENT_ACTION, 1); 101 | store.select().subscribe(state => { 102 | expect(state.counter).to.equal(1); 103 | sliceStore.select().subscribe(counter => { 104 | expect(counter).to.equal(1); 105 | done(); 106 | }); 107 | }); 108 | }); 109 | 110 | it("should be possible to add an action by string on the root and dispatch it on the slice", done => { 111 | store.addReducer(INCREMENT_ACTION, incrementReducer); 112 | sliceStore.dispatch(INCREMENT_ACTION, 1); 113 | store.select().subscribe(state => { 114 | expect(state.counter).to.equal(1); 115 | sliceStore.select().subscribe(counter => { 116 | expect(counter).to.equal(1); 117 | done(); 118 | }); 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /test/test_reducer.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { of, range, Subject } from "rxjs"; 4 | import { skip, take, toArray } from "rxjs/operators"; 5 | import { Reducer, Store } from "../src/index"; 6 | import { ExampleState, SliceState } from "./test_common_types"; 7 | 8 | describe("Reducer tests", () => { 9 | let store: Store; 10 | let slice: Store; 11 | 12 | beforeEach(() => { 13 | store = Store.create(); 14 | slice = store.createSlice("counter", 0); 15 | }); 16 | 17 | afterEach(() => { 18 | store.destroy(); 19 | }); 20 | 21 | it("should be possible to add a reducer", done => { 22 | // This is a compile time test: we do not want to give a generic type argument to addReducer 23 | // but compiling with a incompatible reducer will result in compile errors 24 | // Note: type arguments not expressed on purpose for this test! 25 | const addAction = new Subject(); 26 | const addReducer = (state, n) => state + n; 27 | slice.addReducer(addAction, addReducer); 28 | 29 | slice 30 | .select() 31 | .pipe( 32 | take(2), 33 | toArray(), 34 | ) 35 | .subscribe(s => { 36 | expect(s).to.deep.equal([0, 1]); 37 | done(); 38 | }); 39 | 40 | addAction.next(1); 41 | }); 42 | 43 | it("should be possible to add a reducer with an Observable as action", done => { 44 | // Note: type arguments not expressed on purpose for this test! 45 | const addAction = of(1); 46 | const addReducer: Reducer = (state, n) => state + n; 47 | 48 | slice 49 | .select() 50 | .pipe( 51 | take(2), 52 | toArray(), 53 | ) 54 | .subscribe(s => { 55 | expect(s).to.deep.equal([0, 1]); 56 | done(); 57 | }); 58 | 59 | slice.addReducer(addAction, addReducer); 60 | }); 61 | 62 | it("should not be possible to pass anything else but observable/string as first argument to addReducer", () => { 63 | expect(() => { 64 | store.addReducer(5 as any, state => state); 65 | }).to.throw(); 66 | }); 67 | 68 | it("should not be possible to pass non-function argument as reducer to addReducer", () => { 69 | expect(() => { 70 | store.addReducer("foo", 5 as any); 71 | }).to.throw(); 72 | }); 73 | 74 | it("should not invoke reducers which have been unsubscribed", done => { 75 | const incrementAction = new Subject(); 76 | const subscription = store.addReducer(incrementAction, (state, payload) => { 77 | return { ...state, counter: state.counter + payload }; 78 | }); 79 | 80 | store 81 | .select() 82 | .pipe( 83 | skip(1), 84 | toArray(), 85 | ) 86 | .subscribe(states => { 87 | expect(states[0].counter).to.equal(1); 88 | expect(states.length).to.equal(1); 89 | done(); 90 | }); 91 | 92 | incrementAction.next(1); 93 | subscription.unsubscribe(); 94 | incrementAction.next(1); 95 | store.destroy(); 96 | }); 97 | 98 | it("should be possible to omit the payload type argument in reducers", done => { 99 | // This is a compile-time only test to verify the API works nicely. 100 | 101 | const incrementReducer: Reducer = state => state + 1; 102 | const incrementAction = new Subject(); 103 | slice.addReducer(incrementAction, incrementReducer); 104 | slice 105 | .select() 106 | .pipe(skip(1)) 107 | .subscribe(n => { 108 | expect(n).to.equal(1); 109 | done(); 110 | }); 111 | incrementAction.next(); 112 | }); 113 | 114 | it("should be possible to have reducers on lots of slices and have each reducer act on a slice", done => { 115 | const nestingLevel = 100; 116 | const rootStore = Store.create({ foo: "0", slice: undefined }); 117 | 118 | let left = nestingLevel; 119 | const allDone = () => { 120 | left--; 121 | if (left == 1) done(); 122 | }; 123 | 124 | let currentStore = rootStore; 125 | range(1, nestingLevel).subscribe(n => { 126 | const nestedStore = currentStore.createSlice("slice", { foo: "" }) as Store; 127 | 128 | const nAsString = n.toString(); 129 | const fooAction = new Subject(); 130 | const fooReducer: Reducer = (state, payload) => ({ ...state, foo: payload }); 131 | nestedStore.addReducer(fooAction, fooReducer); 132 | nestedStore 133 | .select() 134 | .pipe( 135 | skip(1), 136 | take(1), 137 | ) 138 | .subscribe(s => { 139 | expect(s!.foo).to.equal(nAsString); 140 | allDone(); 141 | }); 142 | 143 | fooAction.next(nAsString); 144 | currentStore = nestedStore; 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /test/test_devtool.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { expect } from "chai"; 3 | import { Subscription, Subject, range, zip } from "rxjs"; 4 | import { take, toArray } from "rxjs/operators"; 5 | import { Store, Reducer } from "../src/index"; 6 | import { ExampleState } from "./test_common_types"; 7 | 8 | describe("Devtool notification tests", () => { 9 | let notifyOnStateChange = (store: Store) => store.stateChangedNotification; 10 | 11 | let store: Store; 12 | let incrementAction: Subject; 13 | let incrementReducer: Reducer; 14 | let incrementReducerSubscription: Subscription; 15 | 16 | beforeEach(() => { 17 | const initialState = { 18 | counter: 0, 19 | }; 20 | store = Store.create(initialState); 21 | incrementAction = new Subject(); 22 | incrementReducer = (state, payload = 1) => ({ ...state, counter: state.counter + payload }); 23 | incrementReducerSubscription = store.addReducer(incrementAction, incrementReducer); 24 | }); 25 | 26 | afterEach(() => { 27 | store.destroy(); 28 | }); 29 | 30 | it("should call the devtool callback function when a state change occurs", done => { 31 | notifyOnStateChange(store).subscribe(({ newState }) => { 32 | expect(newState).to.deep.equal({ counter: 1 }); 33 | done(); 34 | }); 35 | incrementAction.next(); 36 | }); 37 | 38 | // This was changed in v3 - we can't relay on reference equal as our projections might change 39 | // that. Consuming APIs must implement their on distinctUntilChanged() 40 | it.skip("should not call the devtool callback function when the reducer returned the previous state", done => { 41 | const initialState = {}; 42 | const store = Store.create(initialState); 43 | const identityAction = new Subject(); 44 | store.addReducer(identityAction, state => state, "IDENTITY"); 45 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => { 46 | done("Error, notifyOnStateChange called by action: " + actionName); 47 | }); 48 | 49 | identityAction.next(undefined); 50 | setTimeout(done, 50); 51 | }); 52 | 53 | it("should call the devtool callback function with the correct payload when a state change occurs", done => { 54 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => { 55 | expect(actionPayload).to.equal(3); 56 | done(); 57 | }); 58 | incrementAction.next(3); 59 | }); 60 | 61 | it("should use the overriden action name when one is given to addReducer", done => { 62 | incrementReducerSubscription.unsubscribe(); 63 | 64 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => { 65 | expect(newState).to.deep.equal({ counter: 1 }); 66 | expect(actionName).to.equal("CUSTOM_ACTION_NAME"); 67 | done(); 68 | }); 69 | 70 | store.addReducer(incrementAction, incrementReducer, "CUSTOM_ACTION_NAME"); 71 | incrementAction.next(); 72 | }); 73 | 74 | it("should trigger a state change notification on a slice", done => { 75 | const slice = store.createSlice("counter"); 76 | 77 | notifyOnStateChange(slice).subscribe(({ actionName, actionPayload, newState }) => { 78 | expect(newState).to.deep.equal({ counter: 1 }); 79 | expect(actionName).to.equal("INCREMENT_ACTION"); 80 | expect(actionPayload).to.equal(1); 81 | done(); 82 | }); 83 | 84 | const incrementAction = new Subject(); 85 | const incrementReducer: Reducer = (state, payload = 1) => state + payload; 86 | slice.addReducer(incrementAction, incrementReducer, "INCREMENT_ACTION"); 87 | 88 | incrementAction.next(1); 89 | }); 90 | 91 | it("should trigger a state change notification on the parent if a slice changes", done => { 92 | const store = Store.create({ counter: 0 }); 93 | notifyOnStateChange(store).subscribe(notification => { 94 | expect(notification.actionName).to.equal("INCREMENT_ACTION"); 95 | expect(notification.actionPayload).to.equal(1); 96 | expect(notification.newState).to.deep.equal({ counter: 1 }); 97 | done(); 98 | }); 99 | const slice = store.createSlice("counter"); 100 | const incrementAction = new Subject(); 101 | const incrementReducer: Reducer = (state, payload = 1) => state + payload; 102 | slice.addReducer(incrementAction, incrementReducer, "INCREMENT_ACTION"); 103 | 104 | incrementAction.next(1); 105 | }); 106 | 107 | it("should trigger the correct actions matching to the state", done => { 108 | const setValueAction = new Subject(); 109 | const store = Store.create({ value: 0 }); 110 | const N_ACTIONS = 100000; 111 | store.addReducer(setValueAction, (state, value) => ({ value }), "SET_VALUE"); 112 | 113 | const counter1 = new Subject(); 114 | const counter2 = new Subject(); 115 | // finish after 100 actions dispatched 116 | 117 | zip(counter1, counter2) 118 | .pipe( 119 | take(N_ACTIONS), 120 | toArray(), 121 | ) 122 | .subscribe(() => done()); 123 | 124 | notifyOnStateChange(store).subscribe(({ actionName, actionPayload, newState }) => { 125 | expect(newState.value).to.equal(actionPayload); 126 | expect(actionName).to.equal("SET_VALUE"); 127 | counter2.next(); 128 | }); 129 | 130 | range(1, N_ACTIONS).subscribe(n => { 131 | // random wait between 1 ms and 500ms 132 | const wait = Math.ceil(Math.random() * 500); 133 | setTimeout(() => { 134 | setValueAction.next(n); 135 | counter1.next(); 136 | }, wait); 137 | }); 138 | }).timeout(10000); 139 | }); 140 | -------------------------------------------------------------------------------- /react/connect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Subscription, Observable, ReplaySubject } from "rxjs"; 3 | import { Store } from "../src/store"; 4 | import { StoreConsumer } from "./provider"; 5 | 6 | import { ActionMap, assembleActionProps } from "./actions"; 7 | import { takeUntil } from "rxjs/operators"; 8 | 9 | // Allows to get the props of a component, or pass the props themselves. 10 | // See: https://stackoverflow.com/questions/50084643/typescript-conditional-types-extract-component-props-type-from-react-component/50084862#50084862 11 | export type ExtractProps = TComponentOrTProps extends React.ComponentType 12 | ? TProps 13 | : TComponentOrTProps; 14 | 15 | export interface ConnectResult { 16 | props?: Observable; 17 | actionMap?: ActionMap; 18 | } 19 | 20 | export type ConnectCallback = ( 21 | store: Store, 22 | inputProps: Observable, 23 | ) => ConnectResult; 24 | 25 | export interface ConnectState { 26 | connectedProps?: TConnectedProps; 27 | ready: boolean; 28 | } 29 | 30 | /** 31 | * Connects a Component's props to a set of props of the application state coming from a Store object. 32 | */ 33 | // TODO: earlier TS version could infer TOriginalProps, why is this not working anymore? Bug in TS? 34 | // possible candidate: https://github.com/Microsoft/TypeScript/issues/21734 35 | export function connect( 36 | ComponentToConnect: React.ComponentType, 37 | connectCallback: ConnectCallback, 38 | ) { 39 | class ConnectedComponent extends React.Component< 40 | Omit & { reactiveStateStore: Store }, 41 | ConnectState 42 | > { 43 | private subscription: Subscription = new Subscription(); 44 | private actionProps: Partial = {}; 45 | private connectResult?: ConnectResult; 46 | private parentDestroyed?: Observable; 47 | private inputProps = new ReplaySubject(1); 48 | 49 | /** 50 | * we might use the connected component without a store (i.e. in test scenarios). In this case we do 51 | * not do anything and just behave as if we were not connected at all. So we allow undefined here. 52 | */ 53 | private store?: Store; 54 | 55 | state: ConnectState = { 56 | connectedProps: undefined, 57 | ready: false, 58 | }; 59 | 60 | constructor(props: TInputProps) { 61 | super(props as any); 62 | 63 | this.inputProps.next(this.getProps()); 64 | this.subscription.add(() => this.inputProps.complete()); 65 | 66 | if (this.props.reactiveStateStore) { 67 | this.store = this.props.reactiveStateStore.clone(); 68 | // TODO this hack is necesseary because we seem to have a bug in the destroy logic for clones 69 | this.parentDestroyed = this.props.reactiveStateStore.destroyed; 70 | } 71 | this.connect(); 72 | } 73 | 74 | private connect() { 75 | if (this.store === undefined) return; 76 | 77 | this.connectResult = connectCallback(this.store, this.inputProps.asObservable()); 78 | 79 | if (this.connectResult.actionMap) { 80 | this.actionProps = assembleActionProps(this.connectResult.actionMap) as TOriginalProps; 81 | } 82 | } 83 | 84 | private subscribeToStateChanges() { 85 | if (this.store === undefined) return; 86 | 87 | const connectResult = this.connectResult!; 88 | if (connectResult.props) { 89 | this.subscription.add( 90 | connectResult.props.pipe(takeUntil(this.parentDestroyed!)).subscribe(connectedProps => { 91 | this.setState((prevState: ConnectState) => { 92 | return { 93 | ...prevState, 94 | connectedProps, 95 | ready: true, 96 | }; 97 | }); 98 | }), 99 | ); 100 | } else { 101 | this.setState((prevState: ConnectState) => ({ ready: true })); 102 | } 103 | } 104 | 105 | /** 106 | * We need to remove the remoteReacticeState properties from our input props; the remainder input props 107 | * are passed down to the connected component 108 | */ 109 | private getProps(): TInputProps { 110 | const props: TInputProps & { reactiveStateStore: any } = { ...(this.props as any) }; 111 | delete props.reactiveStateStore; 112 | return props; 113 | } 114 | 115 | componentWillUnmount() { 116 | if (this.store !== undefined) { 117 | this.store.destroy(); 118 | } 119 | this.subscription.unsubscribe(); 120 | } 121 | 122 | componentDidMount() { 123 | this.subscribeToStateChanges(); 124 | } 125 | 126 | componentDidUpdate(prevProps: any) { 127 | if (prevProps !== this.props) { 128 | this.inputProps.next(this.getProps()); 129 | } 130 | } 131 | 132 | render() { 133 | const props = this.getProps(); 134 | 135 | if (this.store === undefined || this.state.ready === true) { 136 | return ( 137 | 142 | ); 143 | } else { 144 | return null; 145 | } 146 | } 147 | } 148 | 149 | return class extends React.Component, ConnectState> { 150 | constructor(props: any) { 151 | super(props); 152 | } 153 | 154 | render() { 155 | return ( 156 | 157 | {value => } 158 | 159 | ); 160 | } 161 | }; 162 | } 163 | -------------------------------------------------------------------------------- /react/provider.tsx: -------------------------------------------------------------------------------- 1 | import { Store } from "../src/index"; 2 | import * as React from "react"; 3 | 4 | const context = React.createContext | undefined>(undefined); 5 | const { Provider, Consumer } = context; 6 | 7 | export interface StoreProviderProps { 8 | store: Store<{}>; 9 | } 10 | 11 | export class StoreProvider extends React.Component { 12 | render() { 13 | return {this.props.children}; 14 | } 15 | } 16 | 17 | export const StoreConsumer = Consumer; 18 | 19 | export interface StoreSliceProps { 20 | slice: (store: Store) => TKey; 21 | initialState?: TAppState[TKey]; 22 | cleanupState?: TAppState[TKey] | "delete" | "undefined"; 23 | } 24 | 25 | export class StoreSlice extends React.Component< 26 | StoreSliceProps, 27 | {} 28 | > { 29 | slice?: Store; 30 | 31 | componentWillUnmount() { 32 | this.slice!.destroy(); 33 | } 34 | 35 | render() { 36 | return ( 37 | 38 | {(store: Store | undefined) => { 39 | if (!store) 40 | throw new Error( 41 | "StoreSlice used outside of a Store context. Did forget to add a ?", 42 | ); 43 | 44 | // we ignore this else due to a limitation in enzyme - we can't trigger a 45 | // forceUpdate here to test the else branch; 46 | /* istanbul ignore else */ 47 | if (this.slice === undefined) { 48 | this.slice = store.createSlice( 49 | this.props.slice(store), 50 | this.props.initialState, 51 | this.props.cleanupState, 52 | ); 53 | } 54 | return {this.props.children}; 55 | }} 56 | 57 | ); 58 | } 59 | } 60 | 61 | export interface StoreProjectionProps { 62 | forwardProjection: (state: TState) => TProjected; 63 | backwardProjection: (projectedState: TProjected, parentState: TState) => TState; 64 | cleanup?: (state: TProjected, parentState: TState) => TState; 65 | initial?: (state: TState) => TProjected; 66 | } 67 | 68 | export const StoreProjection = class StoreProjection extends React.Component< 69 | StoreProjectionProps, 70 | {} 71 | > { 72 | slice?: Store; 73 | 74 | componentWillUnmount() { 75 | this.slice!.destroy(); 76 | } 77 | 78 | render() { 79 | return ( 80 | 81 | {(store: Store | undefined) => { 82 | if (!store) 83 | throw new Error( 84 | "StoreProjection/Slice used outside of a Store context. Did forget to add a ?", 85 | ); 86 | 87 | // we ignore this else due to a limitation in enzyme - we can't trigger a 88 | // forceUpdate here to test the else branch; 89 | /* istanbul ignore else */ 90 | if (this.slice === undefined) { 91 | this.slice = store.createProjection( 92 | this.props.forwardProjection, 93 | this.props.backwardProjection, 94 | this.props.initial, 95 | this.props.cleanup, 96 | ); 97 | } 98 | return {this.props.children}; 99 | }} 100 | 101 | ); 102 | } 103 | }; 104 | 105 | export class WithStore extends React.Component<{}, {}> { 106 | render() { 107 | return ( 108 | 109 | {store => { 110 | const child = this.props.children as (store: Store) => React.ReactNode; 111 | if (!store) 112 | throw new Error( 113 | "WithStore used but no store could be found in context. Did you suppliy a StoreProvider?", 114 | ); 115 | else if (typeof this.props.children !== "function") 116 | throw new Error("WithStore used but its child is not a function."); 117 | else return child(store); 118 | }} 119 | 120 | ); 121 | } 122 | } 123 | 124 | /** 125 | * A react hook to obtain the current store, depending on the context. 126 | */ 127 | export function useStore() { 128 | const store = React.useContext(context); 129 | if (store === undefined) { 130 | throw new Error("No store found in context, did you forget to add a Provider for it?"); 131 | } 132 | return store as Store; 133 | } 134 | 135 | /** 136 | * A react hook to mirror the pattern of connect through a hooks-based interface. 137 | */ 138 | export function useStoreState(): object; 139 | export function useStoreState(): TState; 140 | export function useStoreState(projection: (state: TState) => TSlice): TSlice; 141 | export function useStoreState(projection?: (state: TState) => TSlice): TSlice { 142 | const store = useStore(); 143 | const [slice, setSlice] = React.useState(projection ? projection(store.currentState) : store.currentState as unknown as TSlice); 144 | 145 | // do not introduce a unneeded second re-render whenever using this hook 146 | const firstRender = React.useRef(true) 147 | 148 | React.useEffect(() => { 149 | const sub = store.watch(projection).subscribe((slice) => { 150 | if (!firstRender.current) { 151 | setSlice(slice) 152 | } 153 | }); 154 | firstRender.current = false; 155 | return () => sub.unsubscribe(); 156 | }, [store]); 157 | 158 | return slice; 159 | } 160 | 161 | /** 162 | * A react hook to create a fluent interface for producing a hook that makes state slices. 163 | * Useful mainly for infering the type of the slice; when the type of slice is known, useStoreState is cleaner. 164 | */ 165 | export function useStoreSlices(): (projection: (state: TState) => TSlice) => TSlice { 166 | // note: a named function is needed here to keep react devtools looking clean 167 | return function useStoreSlice(projection: (state: TState) => TSlice): TSlice { 168 | return useStoreState(projection); 169 | }; 170 | } -------------------------------------------------------------------------------- /test/test_select.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { Subject } from "rxjs"; 4 | import { skip, take, toArray } from "rxjs/operators"; 5 | import { Reducer, Store } from "../src/index"; 6 | import { ExampleState } from "./test_common_types"; 7 | 8 | describe("Store .select() and .watch() tests", () => { 9 | let store: Store; 10 | let incrementAction: Subject; 11 | let incrementReducer: Reducer; 12 | let mergeAction: Subject>; 13 | let noChangesAction: Subject; 14 | let shallowCopyAction: Subject; 15 | 16 | const mergeReducer = (state, patch) => { 17 | const newState: ExampleState = { 18 | ...state, 19 | someArray: patch.someArray ? [...state.someArray, ...patch.someArray] : state.someArray, 20 | someObject: patch.someObject ? { ...state.someObject, ...patch.someObject } : state.someObject, 21 | }; 22 | return newState; 23 | }; 24 | const noChangesReducer = state => state; 25 | const shallowCopyReducer = state => ({ ...state }); 26 | 27 | const initialState = { 28 | counter: 0, 29 | message: "initialMessage", 30 | bool: false, 31 | someArray: ["Apple", "Banana", "Cucumber"], 32 | someObject: { 33 | foo: "bar", 34 | }, 35 | }; 36 | 37 | beforeEach(() => { 38 | store = Store.create(initialState); 39 | incrementAction = new Subject(); 40 | incrementReducer = state => ({ ...state, counter: state.counter + 1 }); 41 | store.addReducer(incrementAction, incrementReducer); 42 | 43 | mergeAction = new Subject>(); 44 | store.addReducer(mergeAction, mergeReducer); 45 | noChangesAction = new Subject(); 46 | store.addReducer(noChangesAction, noChangesReducer); 47 | shallowCopyAction = new Subject(); 48 | store.addReducer(shallowCopyAction, shallowCopyReducer); 49 | }); 50 | 51 | afterEach(() => { 52 | store.destroy(); 53 | }); 54 | 55 | describe("select(): ", () => { 56 | it("should emit a state change on select", done => { 57 | store 58 | .select() 59 | .pipe( 60 | skip(1), 61 | take(1), 62 | ) 63 | .subscribe(state => { 64 | expect(state.counter).to.equal(1); 65 | done(); 66 | }); 67 | incrementAction.next(); 68 | }); 69 | 70 | it("should use the identity function as default if no selector function is passed", done => { 71 | store 72 | .select() 73 | .pipe( 74 | skip(1), 75 | take(1), 76 | ) 77 | .subscribe(state => { 78 | expect(state).to.be.an("Object"); 79 | expect(state.counter).not.to.be.undefined; 80 | done(); 81 | }); 82 | 83 | incrementAction.next(); 84 | }); 85 | 86 | it("should immediately emit the last-emitted (might be initial) state when subscription happens", done => { 87 | store 88 | .select() 89 | .pipe(take(1)) 90 | .subscribe(state => { 91 | expect(state.counter).to.equal(0); 92 | done(); 93 | }); 94 | }); 95 | 96 | it("should emit the last state immediately when selecting when its not initial state", done => { 97 | incrementAction.next(); 98 | 99 | store 100 | .select() 101 | .pipe(take(1)) 102 | .subscribe(state => { 103 | expect(state.counter).to.equal(1); 104 | done(); 105 | }); 106 | }); 107 | 108 | it("should emit a state change when the state changes, even when the selector result is shallow-equal to the previous value", done => { 109 | store 110 | .select(state => state.message) 111 | .pipe( 112 | skip(1), 113 | take(1), 114 | ) 115 | .subscribe(msg => { 116 | expect(msg).to.equal(initialState.message); 117 | done(); 118 | }); 119 | incrementAction.next(); 120 | }); 121 | }); 122 | 123 | describe(".watch(): ", () => { 124 | it("should not emit a state change for .watch() when the reducer returns the unmofified, previous state or a shallow copy of it", done => { 125 | store 126 | .watch() 127 | .pipe( 128 | skip(1), 129 | toArray(), 130 | ) 131 | .subscribe(state => { 132 | expect(state.length).to.equal(0); 133 | done(); 134 | }); 135 | 136 | noChangesAction.next(); 137 | shallowCopyAction.next(); 138 | store.destroy(); 139 | }); 140 | it(".watch() should not emit a state change when a the state changes but not the selected value", done => { 141 | store 142 | .watch(state => state.counter) 143 | .pipe( 144 | skip(1), 145 | toArray(), 146 | ) 147 | .subscribe(state => { 148 | expect(state.length).to.equal(0); 149 | done(); 150 | }); 151 | 152 | noChangesAction.next(); 153 | shallowCopyAction.next(); 154 | store.destroy(); 155 | }); 156 | 157 | it(".watch() should emit a state change when a primitive type in a selector changes", done => { 158 | store 159 | .watch(state => state.counter) 160 | .pipe( 161 | skip(1), 162 | toArray(), 163 | ) 164 | .subscribe(state => { 165 | expect(state.length).to.equal(1); 166 | done(); 167 | }); 168 | 169 | incrementAction.next(); 170 | store.destroy(); 171 | }); 172 | 173 | it(".watch() should emit a state change when an array is changed immutably", done => { 174 | store 175 | .watch(state => state.someArray) 176 | .pipe( 177 | skip(1), 178 | take(1), 179 | ) 180 | .subscribe(state => { 181 | expect(state).to.deep.equal([...initialState.someArray, "Dades"]); 182 | done(); 183 | }); 184 | 185 | mergeAction.next({ someArray: ["Dades"] }); 186 | }); 187 | 188 | it(".watch() should emit a state change when an object is changed immutably", done => { 189 | store 190 | .watch(state => state.someObject) 191 | .pipe( 192 | skip(1), 193 | take(1), 194 | ) 195 | .subscribe(state => { 196 | expect(state).to.deep.equal({ ...initialState.someObject, foo: "foo" }); 197 | done(); 198 | }); 199 | 200 | mergeAction.next({ someObject: { foo: "foo" } }); 201 | }); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /test/test_slicing.ts: -------------------------------------------------------------------------------- 1 | import "mocha"; 2 | import { expect } from "chai"; 3 | import { Subscription, zip as zipStatic, Subject } from "rxjs"; 4 | import { take, skip, toArray } from "rxjs/operators"; 5 | import { Store, Reducer } from "../src/index"; 6 | 7 | import { ExampleState, RootState, SliceState } from "./test_common_types"; 8 | 9 | describe("Store slicing tests", () => { 10 | let store: Store; 11 | let counterSlice: Store; 12 | let incrementAction: Subject; 13 | let incrementReducer: Reducer; 14 | let incrementSubscription: Subscription; 15 | 16 | beforeEach(() => { 17 | incrementAction = new Subject(); 18 | incrementReducer = state => state + 1; 19 | store = Store.create({ counter: 0 }); 20 | }); 21 | 22 | afterEach(() => { 23 | store.destroy(); 24 | }); 25 | 26 | describe(" using legacy string-based key slicing", () => { 27 | beforeEach(() => { 28 | counterSlice = store.createSlice("counter"); 29 | incrementSubscription = counterSlice.addReducer(incrementAction, incrementReducer); 30 | }); 31 | 32 | it("should emit the initial state when subscribing to a freshly sliced store", done => { 33 | // sync 34 | expect(counterSlice.currentState).to.equal(0); 35 | 36 | // async 37 | counterSlice.select().subscribe(counter => { 38 | expect(counter).to.equal(0); 39 | done(); 40 | }); 41 | }); 42 | 43 | it("should select a slice and emit the slice value", done => { 44 | incrementAction.next(); 45 | // sync 46 | expect(counterSlice.currentState).to.equal(1); 47 | 48 | // async 49 | counterSlice.select().subscribe(counter => { 50 | expect(counter).to.equal(1); 51 | done(); 52 | }); 53 | }); 54 | 55 | it("should be possible to pass a projection function to .select()", done => { 56 | store 57 | .select(state => state.counter) 58 | .pipe( 59 | take(4), 60 | toArray(), 61 | ) 62 | .subscribe(values => { 63 | expect(values).to.deep.equal([0, 1, 2, 3]); 64 | done(); 65 | }); 66 | 67 | incrementAction.next(); 68 | expect(counterSlice.currentState).to.equal(1); 69 | incrementAction.next(); 70 | expect(counterSlice.currentState).to.equal(2); 71 | incrementAction.next(); 72 | expect(counterSlice.currentState).to.equal(3); 73 | }); 74 | 75 | it("should not invoke reducers which have been unsubscribed", done => { 76 | incrementSubscription.unsubscribe(); 77 | 78 | counterSlice 79 | .select() 80 | .pipe(skip(1)) 81 | .subscribe(state => { 82 | done("Error: This should have not been called"); 83 | }); 84 | 85 | incrementAction.next(); 86 | done(); 87 | }); 88 | 89 | it("should emit a state change on the slice if the root store changes even when the subtree is not affected and forceEmitEveryChange is set", done => { 90 | const simpleSubject = new Subject(); 91 | const simpleMutation: Reducer = state => ({ ...state }); 92 | store.addReducer(simpleSubject, simpleMutation); 93 | 94 | counterSlice 95 | .select() 96 | .pipe( 97 | skip(1), 98 | take(1), 99 | ) 100 | .subscribe(counter => { 101 | expect(counter).to.equal(0); 102 | done(); 103 | }); 104 | 105 | simpleSubject.next(); 106 | }); 107 | 108 | it("should not emit a state change on the slice if the root store changes and forceEmitEveryChange is not set", done => { 109 | const simpleSubject = new Subject(); 110 | const simpleMutation: Reducer = state => ({ ...state }); 111 | store.addReducer(simpleSubject, simpleMutation); 112 | 113 | counterSlice 114 | .watch() 115 | .pipe( 116 | skip(1), 117 | toArray(), 118 | ) 119 | .subscribe(changes => { 120 | expect(changes).to.deep.equal([]); 121 | done(); 122 | }); 123 | 124 | simpleSubject.next(); 125 | store.destroy(); 126 | }); 127 | 128 | it("should trigger state changes on slice siblings", done => { 129 | const siblingStore = store.createSlice("counter"); 130 | 131 | // async 132 | siblingStore 133 | .select() 134 | .pipe(skip(1)) 135 | .subscribe(n => { 136 | expect(n).to.equal(1); 137 | done(); 138 | }); 139 | 140 | incrementAction.next(); 141 | 142 | // sync 143 | expect(siblingStore.currentState).to.equal(1); 144 | }); 145 | 146 | it("should trigger state changes on slice siblings for complex states", done => { 147 | const rootStore: Store = Store.create({ 148 | slice: { foo: "bar" }, 149 | }); 150 | const action = new Subject(); 151 | const reducer: Reducer = state => { 152 | return { ...state, foo: "baz" }; 153 | }; 154 | 155 | const slice1 = rootStore.createSlice("slice", { foo: "bar" }); 156 | // TODO eliminate any 157 | slice1.addReducer(action, reducer as any); 158 | 159 | const slice2 = rootStore.createSlice("slice", { foo: "bar2" }); 160 | slice2 161 | .select() 162 | .pipe(skip(1)) 163 | .subscribe(slice => { 164 | if (!slice) { 165 | done("ERROR"); 166 | return; 167 | } else { 168 | expect(slice.foo).to.equal("baz"); 169 | done(); 170 | } 171 | }); 172 | 173 | action.next(); 174 | }); 175 | }); 176 | 177 | describe(" using projection based slicing", () => { 178 | it("should be possible to create a clone (with identity projections) and their states should be equal", done => { 179 | const slice = store.clone(); 180 | 181 | store.select().subscribe(storeState => { 182 | slice.select().subscribe(sliceState => { 183 | expect(storeState).to.equal(sliceState); 184 | expect(storeState).to.deep.equal(sliceState); 185 | done(); 186 | }); 187 | }); 188 | }); 189 | 190 | it("should be possible to create a clone (with identity projections) and after reducing, their states should be equal", done => { 191 | const slice = store.clone(); 192 | slice.addReducer(incrementAction, state => ({ counter: state.counter + 1 })); 193 | 194 | incrementAction.next(); 195 | 196 | // sync 197 | expect(slice.currentState).to.equal(store.currentState); 198 | 199 | store.select().subscribe(storeState => { 200 | // async 201 | slice.select().subscribe(sliceState => { 202 | expect(storeState).to.equal(sliceState); 203 | expect(storeState).to.deep.equal(sliceState); 204 | done(); 205 | }); 206 | }); 207 | }); 208 | 209 | it("should change both states in clone and original but fire a NamedObservable Subject only on the store that registers it", done => { 210 | const slice = store.clone(); 211 | store.addReducer(incrementAction, state => ({ ...state, counter: state.counter + 1 })); 212 | 213 | zipStatic(store.select().pipe(skip(1)), slice.select().pipe(skip(1))).subscribe( 214 | ([originalState, cloneState]) => { 215 | expect(originalState.counter).to.equal(1); 216 | expect(cloneState.counter).to.equal(1); 217 | expect(cloneState).to.deep.equal(originalState); 218 | done(); 219 | }, 220 | ); 221 | 222 | incrementAction.next(); 223 | }); 224 | 225 | it("should change both states in clone and original but fire a NamedObservable Subject only on the store that registers it", done => { 226 | const slice = store.clone(); 227 | store.addReducer("INCREMENT_Subject", state => ({ ...state, counter: state.counter + 1 })); 228 | 229 | zipStatic(store.select().pipe(skip(1)), slice.select().pipe(skip(1))).subscribe( 230 | ([originalState, cloneState]) => { 231 | expect(originalState.counter).to.equal(1); 232 | expect(cloneState.counter).to.equal(1); 233 | expect(cloneState).to.deep.equal(originalState); 234 | done(); 235 | }, 236 | ); 237 | 238 | store.dispatch("INCREMENT_Subject", undefined); 239 | }); 240 | 241 | // was a regression 242 | it("should correctly apply recursive state transformations", done => { 243 | const action = new Subject(); 244 | const is = { 245 | prop: { 246 | someArray: [] as number[], 247 | }, 248 | }; 249 | const rootStore = Store.create(is); 250 | const slice1 = rootStore.createProjection( 251 | state => state.prop, 252 | (state, parent) => ({ ...parent, prop: state }), 253 | ); 254 | // const slice1 = rootStore.createSlice("prop"); 255 | const slice2 = slice1.createSlice("someArray"); 256 | 257 | const reducer: Reducer = state => { 258 | expect(state).to.deep.equal([]); 259 | return [1]; 260 | }; 261 | slice2.addReducer(action, reducer); 262 | 263 | rootStore 264 | .select() 265 | .pipe(skip(1)) 266 | .subscribe(state => { 267 | expect(state).to.deep.equal({ 268 | prop: { 269 | someArray: [1], 270 | }, 271 | }); 272 | rootStore.destroy(); 273 | done(); 274 | }); 275 | 276 | action.next(undefined); 277 | }); 278 | }); 279 | }); 280 | -------------------------------------------------------------------------------- /test/test_initial_state.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import "mocha"; 3 | import { range, Subject } from "rxjs"; 4 | import { skip, take } from "rxjs/operators"; 5 | import { Reducer, Store } from "../src/index"; 6 | import { ExampleState, GenericState, RootState, SliceState } from "./test_common_types"; 7 | 8 | describe("initial state setting", () => { 9 | class Foo {} 10 | let store: Store; 11 | let genericStore: Store; 12 | let genericAction: Subject; 13 | const genericReducer: Reducer = (state, payload) => ({ ...state, value: payload }); 14 | 15 | beforeEach(() => { 16 | store = Store.create(); 17 | genericStore = Store.create(); 18 | genericAction = new Subject(); 19 | genericStore.addReducer(genericAction, genericReducer); 20 | }); 21 | 22 | // justification: Slices can have any type like "number" etc., so makes no sense to initialize with {} 23 | it("should accept an initial state of undefined and create and empty object as initial root state", done => { 24 | const store = Store.create(); 25 | 26 | store 27 | .select() 28 | .pipe(take(1)) 29 | .subscribe(state => { 30 | expect(state).to.be.an("Object"); 31 | expect(Object.getOwnPropertyNames(state)).to.have.lengthOf(0); 32 | done(); 33 | }); 34 | }); 35 | 36 | it("should set the initial state and have it available as currentState immediately", () => { 37 | const initialState = {} 38 | const store = Store.create(initialState); 39 | expect(store.currentState).to.equal(initialState) 40 | }) 41 | 42 | it("should accept an initial state of undefined and use undefined as initial state", done => { 43 | const sliceStore = store.createSlice("slice", undefined); 44 | 45 | sliceStore 46 | .select() 47 | .pipe(take(1)) 48 | .subscribe(initialState => { 49 | expect(initialState).to.be.undefined; 50 | done(); 51 | }); 52 | }); 53 | 54 | it("should accept an initial state object when creating a slice", () => { 55 | const sliceStore = store.createSlice("slice", { foo: "bar" }); 56 | 57 | sliceStore 58 | .select() 59 | .pipe(take(1)) 60 | .subscribe(slice => { 61 | expect(slice).to.be.an("Object"); 62 | expect(Object.getOwnPropertyNames(slice)).to.deep.equal(["foo"]); 63 | expect(slice!.foo).to.equal("bar"); 64 | }); 65 | }); 66 | 67 | it("should set the initial state for a slice-of-a-slice on the sliced state", done => { 68 | const sliceStore = store.createSlice("slice", { foo: "bar" }) as Store; 69 | 70 | store 71 | .select(s => s) 72 | .pipe(skip(1)) 73 | .subscribe(s => { 74 | if (!s.slice || !s.slice.slice) { 75 | done("Error"); 76 | return; 77 | } 78 | expect(s.slice.slice.foo).to.equal("baz"); 79 | expect(Object.getOwnPropertyNames(s.slice)).to.deep.equal(["foo", "slice"]); 80 | expect(Object.getOwnPropertyNames(s.slice.slice)).to.deep.equal(["foo"]); 81 | done(); 82 | }); 83 | 84 | sliceStore.createSlice("slice", { foo: "baz" }); 85 | }); 86 | 87 | it("should not allow non-plain objects for the root store creation as initialState", () => { 88 | expect(() => Store.create(new Foo())).to.throw(); 89 | }); 90 | 91 | it("should not allow non-plain objects for the slice store as initialState", () => { 92 | expect(() => genericStore.createSlice("value", new Foo())).to.throw(); 93 | }); 94 | 95 | it("should not allow non-plain objects for the slice store as cleanupState", () => { 96 | // we have to trick TypeScript compiler for this test 97 | expect(() => genericStore.createSlice("value", undefined, new Foo())).to.throw(); 98 | }); 99 | 100 | it("should allow primitive types, plain object and array as initial state for root store creation", () => { 101 | expect(() => Store.create(null)).not.to.throw(); 102 | expect(() => Store.create(undefined)).not.to.throw(); 103 | expect(() => Store.create("foobar")).not.to.throw(); 104 | expect(() => Store.create(5)).not.to.throw(); 105 | expect(() => Store.create(false)).not.to.throw(); 106 | expect(() => Store.create({})).not.to.throw(); 107 | expect(() => Store.create([])).not.to.throw(); 108 | expect(() => Store.create(Symbol())).not.to.throw(); 109 | }); 110 | 111 | it("should allow primitive types, plain object and array as initial state for slice store creation", () => { 112 | expect(() => genericStore.createSlice("value", null)).not.to.throw(); 113 | expect(() => genericStore.createSlice("value", undefined)).not.to.throw(); 114 | expect(() => genericStore.createSlice("value", "foobar")).not.to.throw(); 115 | expect(() => genericStore.createSlice("value", 5)).not.to.throw(); 116 | expect(() => genericStore.createSlice("value", false)).not.to.throw(); 117 | expect(() => genericStore.createSlice("value", {})).not.to.throw(); 118 | expect(() => genericStore.createSlice("value", [])).not.to.throw(); 119 | expect(() => genericStore.createSlice("value", Symbol())).not.to.throw(); 120 | }); 121 | 122 | it("should allow primitive types, plain object and array as cleanup state for slice store creation", () => { 123 | expect(() => genericStore.createSlice("value", undefined, null)).not.to.throw(); 124 | expect(() => genericStore.createSlice("value", undefined, undefined)).not.to.throw(); 125 | expect(() => genericStore.createSlice("value", undefined, "foobar")).not.to.throw(); 126 | expect(() => genericStore.createSlice("value", undefined, 5)).not.to.throw(); 127 | expect(() => genericStore.createSlice("value", undefined, false)).not.to.throw(); 128 | expect(() => genericStore.createSlice("value", undefined, {})).not.to.throw(); 129 | expect(() => genericStore.createSlice("value", undefined, [])).not.to.throw(); 130 | expect(() => genericStore.createSlice("value", undefined, Symbol())).not.to.throw(); 131 | }); 132 | 133 | it("does not clone the initialState object when creating the root store, so changes to it will be reflected in our root store", done => { 134 | const initialState: ExampleState = { 135 | counter: 0, 136 | }; 137 | 138 | const store = Store.create(initialState); 139 | const counterAction = new Subject(); 140 | const counterReducer: Reducer = (state, payload = 1) => { 141 | // WARNING this is not immutable and should not be done in production code 142 | // we just do it here for the test... 143 | state.counter++; 144 | return state; 145 | }; 146 | 147 | store.addReducer(counterAction, counterReducer); 148 | counterAction.next(); 149 | 150 | // verify currentState (=synchronous state access) works, too 151 | expect(store.currentState.counter).to.equal(1) 152 | 153 | 154 | store.select().subscribe(s => { 155 | expect(initialState.counter).to.equal(1); 156 | done(); 157 | }); 158 | }); 159 | 160 | it("should not create an immutable copy of the initialState object when creating a slice store, so changes to it will be noticed outside the slice", done => { 161 | const initialState: ExampleState = { 162 | counter: 0, 163 | }; 164 | const store = Store.create(initialState); 165 | const counterAction = new Subject(); 166 | const counterReducer: Reducer = (state, payload = 1) => state + payload; 167 | 168 | const slice = store.createSlice("counter"); 169 | slice.addReducer(counterAction, counterReducer); 170 | counterAction.next(); 171 | 172 | slice 173 | .select() 174 | .pipe(take(2)) 175 | .subscribe(s => { 176 | expect(initialState.counter).to.equal(1); 177 | done(); 178 | }); 179 | }); 180 | 181 | it("should be possible to create a lot of nested slices", done => { 182 | const nestingLevel = 100; 183 | const rootStore = Store.create({ foo: "0", slice: undefined }); 184 | 185 | let currentStore: Store = rootStore; 186 | range(1, nestingLevel).subscribe( 187 | n => { 188 | const nestedStore = currentStore.createSlice("slice", { foo: n.toString() }); 189 | nestedStore 190 | .select() 191 | .pipe(take(1)) 192 | .subscribe(state => { 193 | expect(state!.foo).to.equal(n.toString()); 194 | }); 195 | currentStore = nestedStore as Store; 196 | }, 197 | undefined, 198 | done, 199 | ); 200 | }); 201 | 202 | it("should trigger a state change on the root store when the initial state on the slice is created", done => { 203 | store 204 | .select(s => s) 205 | .pipe( 206 | skip(1), 207 | take(1), 208 | ) 209 | .subscribe(state => { 210 | expect(state.slice).not.to.be.undefined; 211 | expect(state.slice).to.have.property("foo"); 212 | if (state.slice) { 213 | expect(state.slice.foo).to.equal("bar"); 214 | done(); 215 | } 216 | }); 217 | 218 | store.createSlice("slice", { foo: "bar" }); 219 | }); 220 | 221 | it("should overwrite an initial state on the slice if the slice key already has a value", done => { 222 | const sliceStore = store.createSlice("slice", { foo: "bar" }); 223 | sliceStore.destroy(); 224 | const sliceStore2 = store.createSlice("slice", { foo: "different" }); 225 | sliceStore2.select().subscribe(state => { 226 | expect(state!.foo).to.equal("different"); 227 | done(); 228 | }); 229 | }); 230 | 231 | it("should set the state to the cleanup value undefined but keep the property on the object, when the slice store is destroyed for case 'undefined'", done => { 232 | const sliceStore = store.createSlice("slice", { foo: "bar" }, "undefined"); 233 | sliceStore.destroy(); 234 | 235 | store.select().subscribe(state => { 236 | expect(state.hasOwnProperty("slice")).to.equal(true); 237 | expect(state.slice).to.be.undefined; 238 | done(); 239 | }); 240 | }); 241 | 242 | it("should remove the slice property on parent state altogether when the slice store is destroyed for case 'delete'", done => { 243 | const sliceStore = store.createSlice("slice", { foo: "bar" }, "delete"); 244 | sliceStore.destroy(); 245 | 246 | store.select().subscribe(state => { 247 | expect(state.hasOwnProperty("slice")).to.equal(false); 248 | expect(Object.getOwnPropertyNames(state)).to.deep.equal([]); 249 | done(); 250 | }); 251 | }); 252 | 253 | it("should set the state to the cleanup value when the slice store is unsubscribed for case null", done => { 254 | const sliceStore = store.createSlice("slice", { foo: "bar" }, null); 255 | sliceStore.destroy(); 256 | 257 | store.select().subscribe(state => { 258 | expect(state.slice).to.be.null; 259 | done(); 260 | }); 261 | }); 262 | 263 | it("should set the state to the cleanup value when the slice store is unsubscribed for case any object", done => { 264 | const sliceStore = store.createSlice("slice", { foo: "bar" }, { foo: "baz" }); 265 | sliceStore.destroy(); 266 | 267 | store.select().subscribe(state => { 268 | expect(state.slice).to.be.deep.equal({ foo: "baz" }); 269 | done(); 270 | }); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /test/test_react_connect.tsx: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import * as Enzyme from "enzyme"; 3 | import "mocha"; 4 | import * as React from "react"; 5 | import { Subject, Subscription, Observable, of } from "rxjs"; 6 | import { take, skip } from "rxjs/operators"; 7 | import { ActionMap, connect, ConnectResult, StoreProvider } from "../react"; 8 | import { ExtractProps } from "../react/connect"; 9 | import { Store } from "../src/index"; 10 | import { setupJSDomEnv } from "./test_enzyme_helper"; 11 | 12 | // Utilities for testing typing 13 | // from https://github.com/type-challenges/type-challenges/blob/master/utils/index.d.ts 14 | export type Expect = T; 15 | export type ExpectTrue = T; 16 | export type ExpectFalse = T; 17 | export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false; 18 | export type NotEqual = true extends Equal ? false : true; 19 | 20 | const globalClicked = new Subject(); 21 | const nextMessage = new Subject(); 22 | 23 | export interface TestState { 24 | message: string; 25 | slice?: SliceState; 26 | } 27 | 28 | export interface SliceState { 29 | sliceMessage: string; 30 | } 31 | 32 | export interface TestComponentProps { 33 | message: string; 34 | onClick: (arg1: any) => void; 35 | } 36 | 37 | export class TestComponent extends React.Component { 38 | render() { 39 | return ( 40 |
41 |

{this.props.message}

42 |
44 | ); 45 | } 46 | 47 | componentDidCatch() {} 48 | } 49 | 50 | function getConnectedComponent(connectResultOverride?: ConnectResult | null) { 51 | return connect( 52 | TestComponent, 53 | (store: Store) => { 54 | store.destroyed.subscribe(() => cleanup.unsubscribe()); 55 | const props = store.createSlice("message").watch(message => ({ message })); 56 | const actionMap: ActionMap = { 57 | onClick: globalClicked, 58 | }; 59 | if (connectResultOverride === null) { 60 | return {}; 61 | } 62 | return { 63 | actionMap, 64 | props, 65 | ...connectResultOverride, 66 | }; 67 | }, 68 | ); 69 | } 70 | 71 | let cleanup: Subscription; 72 | describe("react bridge: connect() tests", () => { 73 | let store: Store; 74 | let mountInsideStoreProvider: (elem: JSX.Element) => Enzyme.ReactWrapper; 75 | let ConnectedTestComponent: any; 76 | 77 | const initialState: TestState = { 78 | message: "initialMessage", 79 | slice: { 80 | sliceMessage: "initialSliceMessage", 81 | }, 82 | }; 83 | 84 | beforeEach(() => { 85 | setupJSDomEnv(); 86 | cleanup = new Subscription(); 87 | ConnectedTestComponent = getConnectedComponent(); 88 | store = Store.create(initialState); 89 | store.addReducer(nextMessage, (state, message) => { 90 | return { 91 | ...state, 92 | message, 93 | }; 94 | }); 95 | store.destroyed.subscribe(() => cleanup.unsubscribe()); 96 | mountInsideStoreProvider = (elem: JSX.Element) => 97 | Enzyme.mount({elem}); 98 | }); 99 | 100 | it("should map a prop from the state to the prop of the component using props observable", () => { 101 | const wrapper = mountInsideStoreProvider(); 102 | const messageText = wrapper.find("h1").text(); 103 | expect(messageText).to.equal(initialState.message); 104 | }); 105 | 106 | it("should receive prop updates from the store using mapStateToProps", () => { 107 | const wrapper = mountInsideStoreProvider(); 108 | expect(wrapper.find("h1").text()).to.equal(initialState.message); 109 | 110 | nextMessage.next("Message1"); 111 | expect(wrapper.find("h1").text()).to.equal("Message1"); 112 | 113 | nextMessage.next("Message2"); 114 | expect(wrapper.find("h1").text()).to.equal("Message2"); 115 | }); 116 | 117 | it("should trigger an action on a callback function in the actionMap", done => { 118 | const wrapper = mountInsideStoreProvider(); 119 | globalClicked.pipe(take(1)).subscribe(() => { 120 | expect(true).to.be.true; 121 | done(); 122 | }); 123 | wrapper.find("button").simulate("click"); 124 | }); 125 | 126 | it("should allow to override props on the connected component", done => { 127 | const onClick = () => { 128 | done(); 129 | }; 130 | const wrapper = mountInsideStoreProvider(); 131 | 132 | const messageText = wrapper.find("h1").text(); 133 | expect(messageText).to.equal("Barfoos"); 134 | wrapper.find("button").simulate("click"); 135 | }); 136 | 137 | it("should use the provided props if there is no store in context", done => { 138 | const clicked = new Subject(); 139 | const onClick = () => setTimeout(() => done(), 50); 140 | clicked.subscribe(() => { 141 | done("Error: called the subject"); 142 | }); 143 | const wrapper = Enzyme.mount(); 144 | const messageText = wrapper.find("h1").text(); 145 | expect(messageText).to.equal("Barfoos"); 146 | wrapper.find("button").simulate("click"); 147 | wrapper.unmount(); 148 | }); 149 | 150 | it("should use a props if it updated later on", done => { 151 | const Root: React.SFC<{ message?: string }> = props => { 152 | return ( 153 | 154 | 155 | 156 | ); 157 | }; 158 | const wrapper = Enzyme.mount(); 159 | const textMessage = wrapper.find("h1").text(); 160 | // we provided a message props - even though its undefined at first, its mere presence should supersede the 161 | // connected prop of message 162 | expect(textMessage).to.equal(""); 163 | setTimeout(() => { 164 | wrapper.setProps({ message: "Bla" }); 165 | const textMessage = wrapper.find("h1").text(); 166 | expect(textMessage).to.equal("Bla"); 167 | done(); 168 | }, 50); 169 | }); 170 | 171 | it("unsubscribe the cleanup subscription on component unmount", done => { 172 | cleanup.add(() => done()); 173 | const wrapper = mountInsideStoreProvider(); 174 | wrapper.unmount(); 175 | }); 176 | 177 | it("should allow the connect callback to return empty result object and then use the provided props", done => { 178 | ConnectedTestComponent = getConnectedComponent(null); 179 | const onClick = () => done(); 180 | const wrapper = mountInsideStoreProvider(); 181 | const textMessage = wrapper.find("h1").text(); 182 | expect(textMessage).to.equal("Bla"); 183 | wrapper.find("button").simulate("click"); 184 | }); 185 | 186 | it("should allow an observer in an actionMap", done => { 187 | const onClick = new Subject(); 188 | const actionMap: ActionMap = { 189 | onClick, 190 | }; 191 | onClick.subscribe(() => done()); 192 | ConnectedTestComponent = getConnectedComponent({ actionMap }); 193 | const wrapper = mountInsideStoreProvider(); 194 | wrapper.find("button").simulate("click"); 195 | }); 196 | 197 | it("should allow callback functions in an actionMap", done => { 198 | const actionMap: ActionMap = { 199 | onClick: () => done(), 200 | }; 201 | ConnectedTestComponent = getConnectedComponent({ actionMap }); 202 | const wrapper = mountInsideStoreProvider(); 203 | wrapper.find("button").simulate("click"); 204 | }); 205 | 206 | it("should throw an error for invalid entries in the action map", () => { 207 | const actionMap: ActionMap = { 208 | onClick: 5 as any, 209 | }; 210 | expect(() => { 211 | ConnectedTestComponent = getConnectedComponent({ actionMap }); 212 | const wrapper = mountInsideStoreProvider(); 213 | wrapper.find("button").simulate("click"); 214 | }).to.throw(); 215 | }); 216 | 217 | it("should allow undefined fields in an actionMap to ignore callbacks", done => { 218 | const actionMap: ActionMap = { 219 | onClick: undefined, 220 | }; 221 | ConnectedTestComponent = getConnectedComponent({ actionMap }); 222 | cleanup.add(() => done()); 223 | const wrapper = mountInsideStoreProvider(); 224 | wrapper.find("button").simulate("click"); 225 | wrapper.unmount(); 226 | }); 227 | 228 | // Typing regression 229 | it("should be possible for props to operate on any store/slice", () => { 230 | const ConnectedTestComponent = connect( 231 | TestComponent, 232 | (store: Store) => { 233 | const slice = store.createSlice("message", "Blafoo"); 234 | const props = slice.watch(message => ({ message })); 235 | 236 | return { 237 | props, 238 | }; 239 | }, 240 | ); 241 | 242 | const wrapper = mountInsideStoreProvider( {}} />); 243 | const messageText = wrapper.find("h1").text(); 244 | expect(messageText).to.equal("Blafoo"); 245 | }); 246 | 247 | it("should be possible to add additional props to a connected component and subscribe to it in the connect callback", done => { 248 | type ComponentProps = { message: string }; 249 | const Component: React.SFC = props =>

{props.message}

; 250 | 251 | // add another prop field to our component 252 | type InputProps = ComponentProps & { additionalProp: number }; 253 | const ConnectedTestComponent = connect( 254 | Component, 255 | (store: Store, inputProps: Observable) => { 256 | inputProps.pipe(take(1)).subscribe(props => { 257 | expect(props).to.deep.equal({ test: 1 }); 258 | }); 259 | 260 | inputProps 261 | .pipe( 262 | skip(1), 263 | take(1), 264 | ) 265 | .subscribe(props => { 266 | expect(props).to.deep.equal({ test: 2 }); 267 | setTimeout(() => done(), 100); 268 | }); 269 | return { 270 | props: of({ message: "Foobar" }), 271 | }; 272 | }, 273 | ); 274 | 275 | const Root: React.SFC = (props: any) => ( 276 | 277 | 278 | 279 | ); 280 | const wrapper = Enzyme.mount(); 281 | wrapper.setProps({ 282 | test: 2, 283 | }); 284 | const messageText = wrapper.find("h1").text(); 285 | expect(messageText).to.equal("Foobar"); 286 | wrapper.unmount(); 287 | }); 288 | 289 | // this is a compilation test to assert that the type inference for connect() works when manually specifing type arguments 290 | it("should be possible to infer the inputProps type", done => { 291 | type ComponentProps = { message: string }; 292 | const Component: React.SFC = props =>

{props.message}

; 293 | 294 | // add another prop field to our component 295 | type InputProps = { additionalProp: number }; 296 | const ConnectedTestComponent = connect( 297 | Component, 298 | (store, inputProps) => { 299 | const props = of({ message: "Foobar", additionalProp: 5 }); 300 | return { props }; 301 | }, 302 | ); 303 | console.info(ConnectedTestComponent); 304 | done(); 305 | }); 306 | 307 | it("should not be possible to pass props that are already passed from the store", (done) => { 308 | const ConnectedTestComponent = connect(TestComponent, (store: Store) => { 309 | const props = store.watch((state) => ({ 310 | message: state.message, 311 | })); 312 | return { props }; 313 | }); 314 | console.info( {}} />); 315 | type TypeTests = [ 316 | Expect, { onClick: (arg1: any) => void }>>, 317 | Expect, TestComponentProps>>, 318 | Expect, Omit>>, 319 | ]; 320 | // To avoid error TS6196: 'TypeTests' is declared but never used. 321 | console.log({} as TypeTests); 322 | done(); 323 | }); 324 | }); 325 | -------------------------------------------------------------------------------- /test/test_react_storeprovider.tsx: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import * as Enzyme from "enzyme"; 3 | import "mocha"; 4 | import * as React from "react"; 5 | import { Subject } from "rxjs"; 6 | import { take, toArray } from "rxjs/operators"; 7 | import { connect, StoreProjection, StoreProvider, StoreSlice, WithStore, useStore } from "../react"; 8 | import { Store } from "../src/index"; 9 | import { setupJSDomEnv } from "./test_enzyme_helper"; 10 | import { SliceState, TestComponent, TestState } from "./test_react_connect"; 11 | import { useStoreState, useStoreSlices } from "../react/provider"; 12 | 13 | describe("react bridge: StoreProvider and StoreSlice tests", () => { 14 | const nextMessage = new Subject(); 15 | let store: Store; 16 | let wrapper: Enzyme.ReactWrapper | null | undefined; 17 | beforeEach(() => { 18 | setupJSDomEnv(); 19 | store = Store.create({ 20 | message: "initialMessage", 21 | slice: { 22 | sliceMessage: "initialSliceMessage", 23 | }, 24 | }); 25 | store.addReducer(nextMessage, (state, message) => { 26 | return { 27 | ...state, 28 | message, 29 | }; 30 | }); 31 | }); 32 | 33 | afterEach(() => { 34 | store.destroy(); 35 | if (wrapper) { 36 | wrapper.unmount(); 37 | wrapper = undefined; 38 | } 39 | }); 40 | 41 | // TODO this test exposes a bug in the destroy logic of .clone(), see connect.tsx TODO 42 | it("can use StoreSlice with an object slice and delete slice state after unmount", done => { 43 | const nextSliceMessage = new Subject(); 44 | 45 | const ConnectedTestComponent = connect( 46 | TestComponent, 47 | (store: Store) => { 48 | const props = store.select(state => { 49 | return { message: state.sliceMessage }; 50 | }); 51 | 52 | store.addReducer(nextSliceMessage, (state, newMessage) => { 53 | return { 54 | ...state, 55 | sliceMessage: newMessage, 56 | }; 57 | }); 58 | return { 59 | props, 60 | }; 61 | }, 62 | ); 63 | 64 | store 65 | .watch(s => s.slice) 66 | .pipe( 67 | take(4), 68 | toArray(), 69 | ) 70 | .subscribe(arr => { 71 | expect(arr[0]!.sliceMessage).to.equal("initialSliceMessage"); 72 | expect(arr[1]!.sliceMessage).to.equal("1"); 73 | expect(arr[2]!.sliceMessage).to.equal("objectslice"); 74 | expect(arr[3]).to.be.undefined; 75 | setTimeout(() => { 76 | done(); 77 | }, 50); 78 | }); 79 | 80 | const initialSliceState: SliceState = { 81 | sliceMessage: "1", 82 | }; 83 | 84 | wrapper = Enzyme.mount( 85 | 86 | ) => "slice"} 88 | initialState={initialSliceState} 89 | cleanupState={"delete"} 90 | > 91 | {}} /> 92 | 93 | , 94 | ); 95 | nextSliceMessage.next("objectslice"); 96 | const messageText = wrapper.find("h1").text(); 97 | expect(messageText).to.equal("objectslice"); 98 | wrapper.unmount(); 99 | wrapper = null; 100 | }); 101 | 102 | it("should be possible for two StoreProvider as siblings to offer different stores", done => { 103 | const store1 = Store.create({ foo: "foo" }); 104 | const store2 = Store.create({ bar: "bar" }); 105 | wrapper = Enzyme.mount( 106 |
107 | 108 | 109 | {store => { 110 | store 111 | .select() 112 | .pipe(take(1)) 113 | .subscribe(state => { 114 | expect(state.foo).to.equal("foo"); 115 | }); 116 | return

foo

; 117 | }} 118 |
119 |
120 | 121 | 122 | {store => { 123 | store 124 | .select() 125 | .pipe(take(1)) 126 | .subscribe(state => { 127 | expect(state.bar).to.equal("bar"); 128 | setTimeout(() => { 129 | done(); 130 | }, 50); 131 | }); 132 | return

bar

; 133 | }} 134 |
135 |
136 |
, 137 | ); 138 | }); 139 | 140 | it("should allow StoreProvider to be nested and return the correct instances for WithStore", () => { 141 | const store1 = Store.create({ level: "level1" }); 142 | const store2 = Store.create({ level: "level2" }); 143 | 144 | wrapper = Enzyme.mount( 145 |
146 | 147 | 148 | {level1Store => { 149 | level1Store 150 | .select() 151 | .pipe(take(1)) 152 | .subscribe(state => { 153 | expect(state.level).to.equal("level1"); 154 | }); 155 | return ( 156 | 157 | 158 | {level2Store => { 159 | level2Store 160 | .select() 161 | .pipe(take(1)) 162 | .subscribe(state => { 163 | expect(state.level).to.equal("level2"); 164 | }); 165 | return

Foobar

; 166 | }} 167 |
168 |
169 | ); 170 | }} 171 |
172 |
173 |
, 174 | ); 175 | }); 176 | 177 | it("should allow StoreProvider to be nested and return the correct instances for connect", () => { 178 | const store1 = Store.create({ level: "level1" }); 179 | const store2 = Store.create({ level: "level2" }); 180 | const ConnectedTestComponent = connect( 181 | TestComponent, 182 | (store: Store<{ level: string }>) => { 183 | const props = store.select(state => ({ message: state.level })); 184 | return { 185 | props, 186 | }; 187 | }, 188 | ); 189 | 190 | wrapper = Enzyme.mount( 191 | 192 | {}} /> 193 | 194 | {}} /> 195 | 196 | , 197 | ); 198 | 199 | const text1 = wrapper 200 | .find("h1") 201 | .at(0) 202 | .text(); 203 | const text2 = wrapper 204 | .find("h1") 205 | .at(1) 206 | .text(); 207 | expect(text1).to.equal("level1"); 208 | expect(text2).to.equal("level2"); 209 | }); 210 | 211 | it("should assert the store slice is destroyed when the StoreSlice component unmounts", done => { 212 | const ConnectedTestComponent = connect( 213 | TestComponent, 214 | (store: Store) => { 215 | store.destroyed.subscribe(() => done()); 216 | return {}; 217 | }, 218 | ); 219 | 220 | wrapper = Enzyme.mount( 221 | 222 | ) => "slice"}> 223 | {}} message="Test" /> 224 | 225 | , 226 | ); 227 | wrapper.update(); 228 | wrapper.update(); 229 | wrapper.unmount(); 230 | wrapper = null; 231 | }); 232 | 233 | it("can use StoreSlice with a string slice", () => { 234 | const ConnectedTestComponent = connect( 235 | TestComponent, 236 | (store: Store) => { 237 | const props = store.select(message => ({ message })); 238 | return { 239 | props, 240 | }; 241 | }, 242 | ); 243 | 244 | wrapper = Enzyme.mount( 245 | 246 | ) => "message"}> 247 | {}} /> 248 | 249 | , 250 | ); 251 | nextMessage.next("stringslice"); 252 | const messageText = wrapper.find("h1").text(); 253 | expect(messageText).to.equal("stringslice"); 254 | }); 255 | 256 | it("can use StoreProjection", () => { 257 | const ConnectedTestComponent = connect( 258 | TestComponent, 259 | (store: Store) => { 260 | const props = store.select(message => ({ message })); 261 | return { 262 | props, 263 | }; 264 | }, 265 | ); 266 | 267 | const forward = state => state.message; 268 | const backward = (state: string, parent) => ({ ...parent, message: state }); 269 | 270 | wrapper = Enzyme.mount( 271 | 272 | 273 | {}} /> 274 | 275 | , 276 | ); 277 | nextMessage.next("stringprojection"); 278 | const messageText = wrapper.find("h1").text(); 279 | expect(messageText).to.equal("stringprojection"); 280 | }); 281 | 282 | it("should be possible to get a context store instance with the WithStore render prop", done => { 283 | const SampleSFC: React.SFC<{ store: Store }> = props => { 284 | expect(store).to.be.ok; 285 | store.destroy(); 286 | return null; 287 | }; 288 | store.destroyed.subscribe(() => done()); 289 | 290 | wrapper = Enzyme.mount( 291 |
292 | 293 | {theStore => } 294 | 295 |
, 296 | ); 297 | }); 298 | 299 | it("should throw an error if StoreSlice is used outside of a StoreProvider context", () => { 300 | expect(() => { 301 | Enzyme.mount() => "slice"} />); 302 | }).to.throw(); 303 | }); 304 | 305 | it("should throw an error if StoreProjection is used outside of a StoreProvider context", () => { 306 | const forward = (state: any) => state; 307 | const backward = forward; 308 | 309 | expect(() => { 310 | Enzyme.mount(); 311 | }).to.throw(); 312 | }); 313 | 314 | it("should throw an error if WithStore is used outside of a StoreProvider context", () => { 315 | const SampleSFC: React.SFC<{ store: Store }> = props => { 316 | return null; 317 | }; 318 | expect(() => { 319 | Enzyme.mount({theStore => }); 320 | }).to.throw(); 321 | }); 322 | 323 | it("should throw an error if WithStore is used but no function is supplied as child", () => { 324 | expect(() => { 325 | Enzyme.mount( 326 | 327 | 328 |

Not a function

329 |
330 |
, 331 | ); 332 | }).to.throw(); 333 | }); 334 | 335 | it("should be possible to get a store using the useStore hook", done => { 336 | const TestComponent = () => { 337 | const storeFromHook = useStore(); 338 | expect(storeFromHook).to.equal(store); 339 | done(); 340 | return null; 341 | }; 342 | 343 | Enzyme.mount( 344 | 345 | 346 | , 347 | ); 348 | }); 349 | 350 | it("should throw an error is useStore is used out of context", () => { 351 | const TestComponent = () => { 352 | useStore(); 353 | return null; 354 | }; 355 | expect(() => Enzyme.mount()).to.throw(); 356 | }); 357 | 358 | it("should be possible to get a state slice using useStoreState", done => { 359 | const TestComponent = () => { 360 | const slice = useStoreState(({ message }) => ({ message })); 361 | expect(slice.message).to.equal(store.currentState.message); 362 | done(); 363 | return null; 364 | }; 365 | 366 | wrapper = Enzyme.mount( 367 | 368 | 369 | , 370 | ); 371 | }); 372 | 373 | it("should receive store updates when using useStoreState", done => { 374 | const TestComponent = () => { 375 | const state = useStoreState() 376 | const firstRender = React.useRef(true) 377 | if (!firstRender.current) { 378 | expect(state.message).to.equal("msg2") 379 | done(); 380 | } 381 | firstRender.current = false 382 | return null; 383 | }; 384 | 385 | wrapper = Enzyme.mount( 386 | 387 | 388 | , 389 | ); 390 | 391 | setTimeout(() => { 392 | nextMessage.next("msg2") 393 | }, 50) 394 | }); 395 | 396 | 397 | it("should be possible to get a state slice using useSlicer", done => { 398 | const TestComponent = () => { 399 | const slice = useStoreSlices()(({ message }) => ({ message })); 400 | expect(slice.message).to.equal(store.currentState.message); 401 | done(); 402 | return null; 403 | }; 404 | 405 | wrapper = Enzyme.mount( 406 | 407 | 408 | , 409 | ); 410 | }); 411 | 412 | }); 413 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY, isObservable, Observable, Subject, Subscription, AsyncSubject, BehaviorSubject } from "rxjs"; 2 | import { distinctUntilChanged, filter, map, merge, scan, takeUntil } from "rxjs/operators"; 3 | import { shallowEqual } from "./shallowEqual"; 4 | import { ActionDispatch, CleanupState, Reducer, StateChangeNotification, StateMutation } from "./types"; 5 | 6 | // TODO use typings here 7 | declare var require: any; 8 | const isPlainObject = require("lodash.isplainobject"); 9 | const isObject = require("lodash.isobject"); 10 | 11 | /** 12 | * A function which takes a Payload and return a state mutation function. 13 | */ 14 | type RootReducer = (payload: P) => StateMutation; 15 | 16 | /** 17 | * Creates a state based on a stream of StateMutation functions and an initial state. The returned observable 18 | * is hot and caches the last emitted value (will emit the last emitted value immediately upon subscription). 19 | * @param stateMutators 20 | * @param initialState 21 | */ 22 | function createState(stateMutators: Observable>, initialState: S): BehaviorSubject { 23 | const state = new BehaviorSubject(initialState); 24 | stateMutators.pipe(scan((state: S, reducer: StateMutation) => reducer(state), initialState)).subscribe(state); 25 | 26 | return state; 27 | } 28 | 29 | export class Store { 30 | /** 31 | * Observable that emits when the store was destroyed using the .destroy() function. 32 | */ 33 | public readonly destroyed: Observable; 34 | 35 | // TODO This is truly an BehaviorSubject but due to some typing issues we need to cast it as Observable? 36 | private readonly state: Observable; 37 | 38 | public get currentState(): S { 39 | // TODO see above: this.state is actually a BehaviorSubject but typescript or rxjs typings make trouble 40 | return (this.state as any).value; 41 | } 42 | 43 | /** 44 | * All reducers always produce a state mutation of the original root store type R; 45 | * However, we only now type R for the root store; all other stores may have different type 46 | * so we use any here as the root type. 47 | */ 48 | private readonly stateMutators: Subject>; 49 | 50 | /** 51 | * A list of transformation functions that will transform the state to different projections 52 | * and backwards. Use for scoped reducers. 53 | */ 54 | private readonly forwardProjections: Function[]; 55 | 56 | private readonly backwardProjections: Function[]; 57 | 58 | /** 59 | * Is completed when the slice is unsubscribed and no longer needed. 60 | */ 61 | private readonly _destroyed = new AsyncSubject(); 62 | 63 | /** 64 | * Used for manual dispatches without observables 65 | */ 66 | private readonly actionDispatch: Subject>; 67 | 68 | private readonly stateChangeNotificationSubject: Subject; 69 | 70 | /** 71 | * Only used for debugging purposes (so we can bridge Redux Devtools to the store) 72 | * Note: Do not use in day-to-day code, use .select() instead. 73 | */ 74 | public readonly stateChangedNotification: Observable; 75 | 76 | private constructor( 77 | state: BehaviorSubject, 78 | stateMutators: Subject>, 79 | forwardProjections: Function[], 80 | backwardProjections: Function[], 81 | notifyRootStateChangedSubject: Subject, 82 | actionDispatch: Subject>, 83 | ) { 84 | this.state = state; 85 | this.stateMutators = stateMutators; 86 | this.forwardProjections = forwardProjections; 87 | this.backwardProjections = backwardProjections; 88 | 89 | this.destroyed = this._destroyed.asObservable(); 90 | 91 | this.actionDispatch = actionDispatch; 92 | 93 | this.stateChangeNotificationSubject = notifyRootStateChangedSubject; 94 | this.stateChangedNotification = this.stateChangeNotificationSubject 95 | .asObservable() 96 | .pipe(takeUntil(this.destroyed)); 97 | } 98 | 99 | /** 100 | * Create a new Store based on an initial state 101 | */ 102 | static create(initialState?: S): Store { 103 | if (initialState === undefined) initialState = {} as S; 104 | else { 105 | if (isObject(initialState) && !Array.isArray(initialState) && !isPlainObject(initialState)) 106 | throw new Error("initialState must be a plain object, an array, or a primitive type"); 107 | } 108 | 109 | const stateMutators = new Subject>(); 110 | 111 | const state = createState(stateMutators, initialState); 112 | 113 | const store = new Store(state, stateMutators, [], [], new Subject(), new Subject()); 114 | 115 | // emit a single state mutation so that we emit the initial state on subscription 116 | stateMutators.next(s => s); 117 | return store; 118 | } 119 | 120 | /** 121 | * Creates a new linked store, that Selects a slice on the main store. 122 | * @deprecated 123 | */ 124 | createSlice(key: K, initialState?: S[K], cleanupState?: CleanupState): Store { 125 | if (isObject(initialState) && !Array.isArray(initialState) && !isPlainObject(initialState)) 126 | throw new Error("initialState must be a plain object, an array, or a primitive type"); 127 | if (isObject(cleanupState) && !Array.isArray(cleanupState) && !isPlainObject(cleanupState)) 128 | throw new Error("cleanupState must be a plain object, an array, or a primitive type"); 129 | 130 | const forward = (state: S) => (state as any)[key] as S[K]; 131 | const backward = (state: S[K], parentState: S) => { 132 | (parentState as any)[key] = state; 133 | return parentState; 134 | }; 135 | 136 | const initial = initialState === undefined ? undefined : () => initialState; 137 | 138 | // legacy cleanup for slices 139 | const cleanup = 140 | cleanupState === undefined 141 | ? undefined 142 | : (state: any, parentState: any) => { 143 | if (cleanupState === "undefined") { 144 | parentState[key] = undefined; 145 | } else if (cleanupState === "delete") delete parentState[key]; 146 | else { 147 | parentState[key] = cleanupState; 148 | } 149 | return parentState; 150 | }; 151 | 152 | return this.createProjection(forward, backward, initial, cleanup); 153 | } 154 | 155 | /** 156 | * Create a clone of the store which holds the same state. This is an alias to createProjection with 157 | * the identity functions as forward/backwards projection. Usefull to unsubscribe from select()/watch() 158 | * subscriptions as the destroy() event is specific to the new cloned instance (=will not destroy the original) 159 | * Also usefull to scope string-based action dispatches to .dispatch() as action/reducers pairs added to the 160 | * clone can not be dispatched by the original and vice versa. 161 | */ 162 | clone() { 163 | return this.createProjection((s: S) => s, (s: S, p: S) => s); 164 | } 165 | 166 | /** 167 | * Creates a new slice of the store. The slice holds a transformed state that is created by applying the 168 | * forwardProjection function. To transform the slice state back to the parent state, a backward projection 169 | * function must be given. 170 | * @param forwardProjection - Projection function that transforms a State S to a new projected state TProjectedState 171 | * @param backwardProjection - Back-Projection to obtain state S from already projected state TProjectedState 172 | * @param initial - Function to be called initially with state S that must return an initial state to use for TProjected 173 | * @param cleanup - Function to be called when the store is destroyed to return a cleanup state based on parent state S 174 | */ 175 | createProjection( 176 | forwardProjection: (state: S) => TProjectedState, 177 | backwardProjection: (state: TProjectedState, parentState: S) => S, 178 | // TODO make this a flat object instead of a function? 179 | initial?: (state: S) => TProjectedState, 180 | cleanup?: (state: TProjectedState, parentState: S) => S, 181 | ): Store { 182 | const forwardProjections = [...this.forwardProjections, forwardProjection]; 183 | const backwardProjections = [backwardProjection, ...this.backwardProjections]; 184 | 185 | const initialState = initial 186 | ? initial((this.state as any).value) 187 | : forwardProjection((this.state as any).value); 188 | 189 | if (initial !== undefined) { 190 | this.stateMutators.next(s => { 191 | const initialReducer = () => initialState; 192 | return mutateRootState(s, forwardProjections, backwardProjections, initialReducer); 193 | }); 194 | } 195 | 196 | const state = new BehaviorSubject(initialState); 197 | this.state.pipe(map(state => forwardProjection(state))).subscribe(state); 198 | 199 | const onDestroy = () => { 200 | if (cleanup !== undefined) { 201 | this.stateMutators.next(s => { 202 | const backward = [cleanup, ...this.backwardProjections]; 203 | return mutateRootState(s, forwardProjections, backward, (s: any) => s); 204 | }); 205 | } 206 | }; 207 | 208 | const sliceStore = new Store( 209 | state, 210 | this.stateMutators, 211 | forwardProjections, 212 | backwardProjections, 213 | this.stateChangeNotificationSubject, 214 | this.actionDispatch, 215 | ); 216 | 217 | sliceStore.destroyed.subscribe(undefined, undefined, onDestroy); 218 | 219 | // destroy the slice if the parent gets destroyed 220 | this._destroyed.subscribe(undefined, undefined, () => { 221 | sliceStore.destroy(); 222 | }); 223 | 224 | return sliceStore; 225 | } 226 | 227 | /** 228 | * Adds an Action/Reducer pair. This will make the reducer become active whenever the action observable emits a 229 | * value. 230 | * @param action An observable whose payload will be passed to the reducer on each emit, or a string identifier 231 | * as an action name. In the later case, .dispatch() can be used to manually dispatch actions based 232 | * on their string name. 233 | * @param reducer A reducer function. @see Reducer 234 | * @param actionName An optional name (only used during development/debugging) to assign to the action when an 235 | * Observable is passed as first argument. Must not be specified if the action argument is a string. 236 | */ 237 | addReducer

(action: Observable

| string, reducer: Reducer, actionName?: string): Subscription { 238 | if (typeof action === "string" && typeof actionName !== "undefined") 239 | throw new Error("cannot specify a string-action AND a string alias at the same time"); 240 | if (!isObservable(action) && typeof action !== "string") 241 | throw new Error("first argument must be an observable or a string"); 242 | if (typeof reducer !== "function") throw new Error("reducer argument must be a function"); 243 | if ( 244 | (typeof actionName === "string" && actionName.length === 0) || 245 | (typeof action === "string" && action.length === 0) 246 | ) 247 | throw new Error("action/actionName must have non-zero length"); 248 | 249 | const name = typeof action === "string" ? action : actionName!; 250 | 251 | const actionFromStringBasedDispatch = this.actionDispatch.pipe( 252 | filter(s => s.actionName === name), 253 | map(s => s.actionPayload), 254 | merge(isObservable(action) ? action : EMPTY), 255 | takeUntil(this.destroyed), 256 | ); 257 | 258 | const rootReducer: RootReducer = (payload: P) => rootState => { 259 | // transform the rootstate to a slice by applying all forward projections 260 | const sliceReducer = (slice: any) => reducer(slice, payload); 261 | rootState = mutateRootState(rootState, this.forwardProjections, this.backwardProjections, sliceReducer); 262 | 263 | // Send state change notification 264 | const changeNotification: StateChangeNotification = { 265 | actionName: name, 266 | actionPayload: payload, 267 | newState: rootState, 268 | }; 269 | this.stateChangeNotificationSubject.next(changeNotification); 270 | 271 | return rootState; 272 | }; 273 | 274 | return actionFromStringBasedDispatch.pipe(map(payload => rootReducer(payload))).subscribe(rootStateMutation => { 275 | this.stateMutators.next(rootStateMutation); 276 | }); 277 | } 278 | 279 | /** 280 | * Selects a part of the state using a selector function. If no selector function is given, the identity function 281 | * is used (which returns the state of type S). 282 | * Note: The returned observable does only update when the result of the selector function changed 283 | * compared to a previous emit. A shallow copy test is performed to detect changes. 284 | * This requires that your reducers update all nested properties in 285 | * an immutable way, which is required practice with Redux and also with reactive-state. 286 | * To make the observable emit any time the state changes, use .select() instead 287 | * For correct nested reducer updates, see: 288 | * http://redux.js.org/docs/recipes/reducers/ImmutableUpdatePatterns.html#updating-nested-objects 289 | * 290 | * @param selectorFn A selector function which returns a mapped/transformed object based on the state 291 | * @returns An observable that emits the result of the selector function after a 292 | * change of the return value of the selector function 293 | */ 294 | watch(selectorFn?: (state: S) => T): Observable { 295 | return this.select(selectorFn).pipe(distinctUntilChanged((a, b) => shallowEqual(a, b))); 296 | } 297 | 298 | /** 299 | * Same as .watch() except that EVERY state change is emitted. Use with care, you might want to pipe the output 300 | * to your own implementation of .distinctUntilChanged() or use only for debugging purposes. 301 | */ 302 | select(selectorFn?: (state: S) => T): Observable { 303 | if (!selectorFn) selectorFn = (state: S) => (state); 304 | 305 | const mapped = this.state.pipe( 306 | takeUntil(this._destroyed), 307 | map(selectorFn), 308 | ); 309 | 310 | return mapped; 311 | } 312 | 313 | /** 314 | * Destroys the Store/Slice. All Observables obtained via .select() will complete when called. 315 | */ 316 | destroy(): void { 317 | this._destroyed.next(undefined); 318 | this._destroyed.complete(); 319 | } 320 | 321 | /** 322 | * Manually dispatch an action by its actionName and actionPayload. 323 | * 324 | * This function exists for compatibility reasons, development and devtools. It is not adviced to use 325 | * this function extensively. 326 | * 327 | * Note: While the observable-based actions 328 | * dispatches only reducers registered for that slice, the string based action dispatch here will forward the 329 | * action to ALL stores, (sub-)slice and parent alike so make sure you separate your actions based on the strings. 330 | */ 331 | public dispatch

(actionName: string, actionPayload: P) { 332 | this.actionDispatch.next({ actionName, actionPayload }); 333 | } 334 | } 335 | 336 | function mutateRootState( 337 | rootState: S, 338 | forwardProjections: Function[], 339 | backwardProjections: Function[], 340 | sliceReducer: (state: TSlice) => TSlice, 341 | ) { 342 | // transform the rootstate to a slice by applying all forward projections 343 | let forwardState: any = rootState; 344 | const intermediaryState = [rootState] as any[]; 345 | forwardProjections.map(fp => { 346 | forwardState = fp.call(undefined, forwardState); 347 | intermediaryState.push(forwardState); 348 | }); 349 | // perform the reduction 350 | const reducedState = sliceReducer(forwardState); 351 | 352 | // apply all backward projections to obtain the root state again 353 | let backwardState = reducedState; 354 | [...backwardProjections].map((bp, index) => { 355 | const intermediaryIndex = intermediaryState.length - index - 2; 356 | backwardState = bp.call(undefined, backwardState, intermediaryState[intermediaryIndex]); 357 | }); 358 | 359 | return backwardState; 360 | } 361 | --------------------------------------------------------------------------------