├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── LICENSE ├── README.md ├── __mocks__ └── react.js ├── babel.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── scripts └── copy-types.js ├── src ├── hooks.ts ├── index.ts ├── models.ts ├── stores.ts ├── units │ ├── base-unit.ts │ └── units.ts └── utils │ ├── __mocks__ │ ├── async-hook.helper.ts │ └── use-register-trigger.hook.ts │ ├── async-hook.helper.ts │ └── use-register-trigger.hook.ts ├── tests ├── hooks.spec.ts ├── stores.spec.ts ├── units │ ├── base-unit.spec.ts │ └── units.spec.ts └── utils │ ├── async-hook.helper.spec.ts │ └── use-register-trigger.hook.spec.ts ├── tsconfig.json └── types └── index.d.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'prettier/@typescript-eslint', 6 | 'plugin:prettier/recommended', 7 | ], 8 | plugins: ['@typescript-eslint', 'prettier'], 9 | env: { 10 | browser: true, 11 | jasmine: true, 12 | jest: true, 13 | es6: true, 14 | }, 15 | rules: { 16 | 'prettier/prettier': [ 17 | 'error', 18 | { 19 | endOfLine: 'auto', 20 | }, 21 | ], 22 | '@typescript-eslint/ban-ts-comment': 1, 23 | '@typescript-eslint/no-var-requires': 1, 24 | }, 25 | parser: '@typescript-eslint/parser', 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # ide 107 | .idea 108 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # all ignored except published package, readme and licence 2 | node_modules 3 | src 4 | 5 | .gitignore 6 | 7 | .prettierignore 8 | .prettierrc.json 9 | 10 | rollup.config.js 11 | tsconfig.json 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # build 2 | build 3 | 4 | # static 5 | public 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: node_js 3 | node_js: 4 | - 12 5 | cache: false 6 | install: 7 | - npm install 8 | - npm install -g typescript 9 | jobs: 10 | include: 11 | - stage: validate 12 | script: npm run validate 13 | - stage: test:ci 14 | script: npm run test:CI 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 eLeontev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # linked-store ![Status](https://travis-ci.org/eLeontev/linked-store.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/github/eLeontev/linked-store/badge.svg?branch=master)](https://coveralls.io/github/eLeontev/linked-store?branch=master) 2 | 3 | ## Description 4 | 5 | tiny state-management library inspired by recoil 6 | 7 | ## Installation 8 | 9 | ```shell script 10 | npm install --save linked-store 11 | ``` 12 | 13 | ## API 14 | 15 | ### Stores 16 | 17 | - simpleStore: `(store: T): IStore` 18 | 19 | ##### Usage: 20 | 21 | ```javascript 22 | import { simpleStore } from 'linked-store'; 23 | 24 | const dirtyStore = simpleStore(false); 25 | 26 | dirtyStore.setState(true); 27 | expect(dirtyStore.getState()).toBeTruthy(); 28 | 29 | dirtyStore.resetState(); 30 | expect(dirtyStore.getState()).toBeFalsy(); 31 | 32 | dirtyStore.setState((state) => !state); 33 | expect(dirtyStore.getState()).toBeTruthy(); 34 | ``` 35 | 36 | - derivedStore: `(getter: (get: GetState) => T): IDerivedStore` 37 | 38 | ##### Usage: 39 | 40 | ```javascript 41 | // trivial usage 42 | import { simpleStore, derivedStore } from 'linked-store'; 43 | 44 | const incrementStore = simpleStore(0); 45 | 46 | const incrementX4Store = derivedStore((get) => get(incrementStore) * 4); 47 | 48 | incrementStore.setState(1); 49 | expect(incrementX4Store.getState()).toBe(4); 50 | 51 | incrementStore.setState((state) => state + 1); 52 | expect(incrementX4Store.getState()).toBe(8); 53 | ``` 54 | 55 | ```javascript 56 | // async usage 57 | import { simpleStore, derivedStore } from 'linked-store'; 58 | 59 | const userDetails = { name: 'userName' }; 60 | const fetchUserDetails = () => new Promise((res) => setTimeout(() => res(userDetails))); 61 | 62 | const userIdStore = simpleStore(321); 63 | 64 | const userDetailsStore = derivedStore(async (get) => fetchUserDetails(get(userIdStore))); 65 | 66 | expect(userDetailsStore.isAsync()).toBeTruthy(); 67 | expect(userDetailsStore.getState() instanceof Promise).toBeTruthy(); 68 | 69 | userDetailsStore.getState().then((userDetailsAsResource) => { 70 | expect(userDetailsAsResource).toBe(userDetails); 71 | expect(userDetailsStore.getResource()).toBe(userDetails); 72 | }); 73 | ``` 74 | 75 | ##### Note: 76 | `setState` for `derivedStore` will only trigger current `getter`. Passing any arguments will not affect the state. 77 | Call of `setState` will need only if store's `getter` contains side effects (e.g. fetch/localStorage, etc.) 78 | 79 | ```javascript 80 | const sStore = simpleStore(3) 81 | const dStore = derivedStore((get) => 2 * get(sStore)); 82 | store.setState(); 83 | expect(store.getState()).toBe(6); 84 | 85 | store.setState(20); 86 | expect(store.getState()).toBe(6); 87 | ``` 88 | 89 | #### Utils 90 | 91 | - getAsyncResource: `(store: IDerivedStore): GetStateCallback>` 92 | 93 | ##### Description: 94 | 95 | try to return async state resource and throw error in each request till promise will be resolved 96 | 97 | | Phase | Returned value | 98 | | -------- | --------------------- | 99 | | pending | `throw promise` | 100 | | error | `throw rejectedError` | 101 | | resolved | `return resource` | 102 | 103 | ##### Usage: 104 | 105 | ```javascript 106 | import { simpleStore, derivedStore, getAsyncResource } from 'linked-store'; 107 | 108 | const userDetails = { name: 'userName' }; 109 | const fetchUserDetails = () => new Promise((res) => setTimeout(() => res(userDetails))); 110 | 111 | const userIdStore = simpleStore(321); 112 | 113 | const userDetailsStore = derivedStore(async (get) => fetchUserDetails(get(userIdStore))); 114 | 115 | const getAsyncState = getAsyncResource(userDetailsStore); 116 | 117 | expect(getAsyncState instanceof Function).toBeTruthy(); 118 | 119 | try { 120 | getAsyncState(); 121 | } catch (reason) { 122 | expect(reason).toBe(userDetailsStore.getState()); 123 | expect(reason instanceof Promise).toBeTruthy(); 124 | } 125 | 126 | userDetailsStore.getState().then(() => { 127 | expect(getAsyncState()).toBe(userDetails); 128 | }); 129 | ``` 130 | 131 | ## Hooks 132 | 133 | #### Description: 134 | 135 | all hooks could be looked as primitive `useState` hook with customisation of returned data pair based on the hook's specific (only value/ ony setter / or both) 136 | 137 | #### Details: 138 | 139 | | hook | types | raise re-render | description | 140 | | ------------------- | ------------------------------------------------------------------------ | --------------- | ----------------------------------------------------------------------------------------------------------------- | 141 | | useLinkedStoreValue | `(store: IStore): [State, GetStateHookCallback]` | `true` | Returns state and triggers Component re-render each time state it's changed. | 142 | | useSetLinkedStore | `(store: IStore): SetState` | `false` | Returns state setter and never triggers component re-render. | 143 | | useResetLinkedStore | `(store: IStore): () => void` | `false` | Returns state reset method and never triggers component re-render. | 144 | | useLinkedStore | `(store: IStore): [State, SetState, GetStateHookCallback]` | `true` | Returns pair of store values: state and its setter and triggers Component re-render each time state it's changed. | 145 | 146 | ##### Usage: 147 | 148 | ```jsx 149 | import { 150 | simpleStore, 151 | useLinkedStoreValue, 152 | useSetLinkedStore, 153 | useResetLinkedStore, 154 | useLinkedStore, 155 | } from 'linked-store'; 156 | 157 | const dirtyStore = simpleStore(false); 158 | 159 | const ToggleDirty1 = () => { 160 | const toggleDirty = useSetLinkedStore(dirtyStore); 161 | return ; 162 | }; 163 | const ToggleDirty2 = ({ isDirty }) => { 164 | const toggleDirty = useSetLinkedStore(dirtyStore); 165 | return ; 166 | }; 167 | 168 | const ResetStateComponent = () => { 169 | const resetState = useResetLinkedStore(dirtyStore); 170 | return ; 171 | }; 172 | 173 | const ToggleAndDisplay1 = () => { 174 | const [isDirty, toggleDirty] = useLinkedStore(dirtyStore); 175 | return ( 176 | 179 | ); 180 | }; 181 | const ToggleAndDisplay2 = () => { 182 | const [, toggleDirty, getDirtyState] = useLinkedStore(dirtyStore); 183 | return ( 184 | 187 | ); 188 | }; 189 | const ToggleAndDisplay3 = () => { 190 | const [isDirty, toggleDirty] = useLinkedStore(dirtyStore); 191 | return ( 192 | 195 | ); 196 | }; 197 | 198 | const Component1 = () => { 199 | const [isDirty] = useLinkedStoreValue(dirtyStore); 200 | return isDirty ? null : dirty details; 201 | }; 202 | const Component2 = () => { 203 | const [, getState] = useLinkedStoreValue(dirtyStore); 204 | return getState() ? null : dirty details; 205 | }; 206 | ``` 207 | 208 | ## Advanced usage 209 | 210 | ### Description: 211 | 212 | The derived stores supports async states like `Promise`. To integrate with react they could be used in different ways include experimental concurrent mode. 213 | 214 | ##### Usage: 215 | 216 | ##### without experimental concurrent mode 217 | 218 | ```jsx 219 | import { derivedStore, useLinkedStore } from 'linked-store'; 220 | 221 | const asyncRandomStore = derivedStore( 222 | () => new Promise((res) => setTimeout(() => res(Math.random()), 1000)) 223 | ); 224 | const AsyncStoreComponent = () => { 225 | const [isLoaded, toggleLoaded] = useState(false); 226 | const [randomValue, setValue] = useState(null); 227 | 228 | const [randomValuePromise, updateState] = useLinkedStore(asyncRandomStore); 229 | 230 | useEffect(() => { 231 | toggleLoaded(false); 232 | randomValuePromise 233 | .then((randomValue) => setValue(randomValue)) 234 | .catch(console.error) 235 | .finally(() => toggleLoaded(true)); 236 | }, [randomValuePromise, toggleLoaded, setValue]); 237 | 238 | return isLoaded ? ( 239 | 240 | ) : ( 241 | loading... 242 | ); 243 | }; 244 | 245 | // or 246 | const AsyncStoreComponent = () => { 247 | const { error, isLoading, data } = useAsyncWithLoaderLinkedStore(asyncRandomStore); 248 | return isLoading ? loading... : ; 249 | }; 250 | ``` 251 | 252 | ##### with experimental concurrent mode 253 | 254 | ```jsx 255 | import { 256 | simpleStore, 257 | derivedStore, 258 | useLinkedStore, 259 | useLinkedStoreValue, 260 | useSetLinkedStore, 261 | useResetLinkedStore, 262 | useAsyncLinkedStoreValue, 263 | } from 'linked-store'; 264 | 265 | const getUserDetails = (userId) => ({ name: `${userId === 123 ? 'first' : 'second'} user name` }); 266 | const fetchUserDetails = (userId) => 267 | new Promise((res) => setTimeout(() => res(getUserDetails(userId)), 1000)); 268 | 269 | const dirtyStore = simpleStore(false); 270 | const userIdStore = simpleStore(null); 271 | const userDetailsStore = derivedStore(async (get) => await fetchUserDetails(get(userIdStore))); 272 | const asyncRandomStore = derivedStore( 273 | () => new Promise((res) => setTimeout(() => res(Math.random()), 1000)) 274 | ); 275 | 276 | const UserDetails = () => { 277 | const details = useAsyncLinkedStoreValue(userDetailsStore); 278 | const resetToAllUsers = useResetLinkedStore(userIdStore); 279 | 280 | return ( 281 | <> 282 | {details.name} 283 | 284 | 285 | ); 286 | }; 287 | 288 | const AllUsers = () => { 289 | const setUser = useSetLinkedStore(userIdStore); 290 | return ( 291 |
    292 |
  • setUser(123)}>first user
  • 293 |
  • setUser(321)}>second user
  • 294 |
295 | ); 296 | }; 297 | 298 | const AsyncStoreComponent = ({ getStateValue }) => { 299 | const updateState = useSetLinkedStore(asyncRandomStore); 300 | const randomValue = getStateValue(); 301 | 302 | return ; 303 | }; 304 | 305 | const App = () => { 306 | const [hasSelectedUsers] = useLinkedStoreValue(userIdStore); 307 | const [, getAsyncStateValue] = useLinkedStoreValue(asyncRandomStore); 308 | 309 | return ( 310 | <> 311 | loading}> 312 | {hasSelectedUsers ? : } 313 | 314 | loading}> 315 | 316 | 317 | 318 | ); 319 | }; 320 | ``` 321 | 322 | ## Usage with VanillaJS 323 | 324 | ### Description: 325 | 326 | The store could be integrated or used with any framework you prefer, below is the demonstration of usage with pure javascript 327 | 328 | ##### Usage: 329 | 330 | ```javascript 331 | import { derivedStore } from 'linked-store'; 332 | 333 | const asyncRandomStore = derivedStore( 334 | () => new Promise((res) => setTimeout(() => res(Math.random()), 1000)) 335 | ); 336 | 337 | class RandomRenderer { 338 | constructor(rootId, asyncSore) { 339 | this.rootElement = document.getElementById(rootId); 340 | this.asyncSore = asyncSore; 341 | 342 | this.updateInnerHTML = this.updateInnerHTML.bind(this); 343 | this.render = this.render.bind(this); 344 | 345 | this.registerTrigger(); 346 | } 347 | 348 | render() { 349 | this.performCleanupAndCallRenderrer('loading'); 350 | this.asyncSore.getState().then(this.updateInnerHTML); 351 | } 352 | 353 | updateInnerHTML(innerHTML) { 354 | this.rootElement.innerHTML = ''; 355 | this.rootElement.innerHTML = innerHTML; 356 | } 357 | 358 | registerTrigger() { 359 | this.asyncSore.setTrigger(this.render); 360 | } 361 | 362 | destroy() { 363 | this.asyncSore.removeTrigger(this.render); 364 | } 365 | } 366 | 367 | const randomRenderer = new RandomRenderer('root', asyncRandomStore); 368 | randomRenderer.render(); 369 | 370 | let iterator = 0; 371 | let timerId = setInterval(() => { 372 | iterator += 1; 373 | 374 | asyncRandomStore.setState(); 375 | 376 | if (iterator === 3) { 377 | randomRenderer.destroy(); 378 | clearInterval(timerId); 379 | } 380 | }, 2000); 381 | ``` 382 | -------------------------------------------------------------------------------- /__mocks__/react.js: -------------------------------------------------------------------------------- 1 | const React = {}; 2 | 3 | export const useState = jest.fn().mockName('useState'); 4 | export const useEffect = jest.fn().mockName('useEffect'); 5 | 6 | export default React; 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /** jest config to work with ts files */ 2 | module.exports = { 3 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], 4 | }; 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePaths: [''], 3 | testMatch: ['**/tests/**/*.[jt]s?(x)'], 4 | collectCoverageFrom: ['src/**/*', '!src/models.ts', '!src/index.ts'], 5 | collectCoverage: true, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linked-store", 3 | "version": "0.2.8", 4 | "description": "tiny state-management library inspired by recoil", 5 | "scripts": { 6 | "format": "prettier src/**/*.ts --write", 7 | "validate": "tsc -b && eslint src/**/*.ts", 8 | "set:types": "node ./scripts/copy-types.js", 9 | "build": "rollup -c && npm run set:types", 10 | "publish": "npm publish --access public", 11 | "publish:package": "npm run format && npm run build && npm run publish", 12 | "test": "jest", 13 | "test:CI": "jest && coveralls < coverage/lcov.info" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/eLeontev/linked-store.git" 18 | }, 19 | "author": "", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/eLeontev/linked-store/issues" 23 | }, 24 | "homepage": "https://github.com/eLeontev/linked-store#readme", 25 | "devDependencies": { 26 | "@babel/preset-env": "^7.11.0", 27 | "@babel/preset-typescript": "^7.10.4", 28 | "@rollup/plugin-typescript": "^5.0.2", 29 | "@types/jest": "^26.0.10", 30 | "@types/mocha": "^8.0.3", 31 | "@types/node": "^14.0.26", 32 | "@types/react": "^16.9.43", 33 | "@typescript-eslint/eslint-plugin": "^3.7.1", 34 | "@typescript-eslint/parser": "^3.7.1", 35 | "babel": "^6.23.0", 36 | "coveralls": "^3.1.0", 37 | "eslint": "^6.8.0", 38 | "eslint-config-prettier": "^6.11.0", 39 | "eslint-plugin-prettier": "^3.1.3", 40 | "jest": "^26.4.1", 41 | "prettier": "^2.0.5", 42 | "react": "^16.13.1", 43 | "rollup": "^2.23.0", 44 | "rollup-plugin-terser": "^6.1.0", 45 | "tslib": "^2.0.0", 46 | "typescript": "^3.9.7" 47 | }, 48 | "peerDependencies": { 49 | "react": "^16.13.1" 50 | }, 51 | "files": [ 52 | "dist", 53 | "LICENSE", 54 | "README.md" 55 | ], 56 | "main": "dist/index.js", 57 | "types": "dist/index.d.ts" 58 | } 59 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | 4 | export default [ 5 | { 6 | plugins: [typescript()], 7 | input: 'src/index.ts', 8 | output: { 9 | file: `dist/index.js`, 10 | format: 'es', 11 | name: 'LinkedStore', 12 | exports: 'named', 13 | }, 14 | }, 15 | { 16 | plugins: [typescript()], 17 | input: 'src/index.ts', 18 | output: { 19 | file: `dist/index.production.js`, 20 | format: 'es', 21 | name: 'LinkedStore', 22 | exports: 'named', 23 | plugins: [terser()], 24 | }, 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /scripts/copy-types.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const source = 'types/index.d.ts'; 4 | const destination = 'dist/index.d.ts'; 5 | 6 | fs.copyFileSync(source, destination); 7 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useRegisterTrigger } from './utils/use-register-trigger.hook'; 2 | import { getAsyncResource } from './utils/async-hook.helper'; 3 | 4 | import { 5 | asyncStatuses, 6 | AsyncWithLoaderResult, 7 | GetStateHookCallback, 8 | IDerivedStore, 9 | IStore, 10 | Resource, 11 | SetState, 12 | State, 13 | } from './models'; 14 | 15 | export const useLinkedStoreValue = (store: IStore): [State, GetStateHookCallback] => { 16 | useRegisterTrigger(store); 17 | 18 | const state = store.getState(); 19 | const getState = store.isAsync() 20 | ? getAsyncResource(store as IDerivedStore) 21 | : () => store.getState() as any; // eslint-disable-line @typescript-eslint/no-explicit-any 22 | 23 | return [state, getState]; 24 | }; 25 | 26 | export const useAsyncLinkedStoreValue = (store: IDerivedStore): Resource => { 27 | useRegisterTrigger(store); 28 | return getAsyncResource(store)(); 29 | }; 30 | 31 | export const useSetLinkedStore = (store: IStore): SetState => store.setState; 32 | export const useResetLinkedStore = (store: IStore): (() => void) => store.resetState; 33 | 34 | export const useLinkedStore = ( 35 | store: IStore 36 | ): [State, SetState, GetStateHookCallback] => { 37 | const [state, getState] = useLinkedStoreValue(store); 38 | return [state, useSetLinkedStore(store), getState]; 39 | }; 40 | 41 | export const useAsyncLinkedStore = (store: IDerivedStore): [Resource, SetState] => [ 42 | useAsyncLinkedStoreValue(store), 43 | useSetLinkedStore(store), 44 | ]; 45 | 46 | export const useAsyncWithLoaderLinkedStore = ( 47 | store: IDerivedStore 48 | ): AsyncWithLoaderResult => { 49 | useRegisterTrigger(store); 50 | const status = store.getStatus(); 51 | 52 | const isError = status === asyncStatuses.error; 53 | const isLoading = status === asyncStatuses.pending; 54 | const resource = store.getResource(); 55 | 56 | // TODO: split resource to resource and error 57 | return { 58 | isLoading, 59 | error: isError ? ((resource as unknown) as E) : null, 60 | data: (isError ? null : resource) as Resource, 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { simpleStore, derivedStore } from './units/units'; 2 | export { getAsyncResource } from './utils/async-hook.helper'; 3 | export { 4 | useLinkedStore, 5 | useLinkedStoreValue, 6 | useResetLinkedStore, 7 | useSetLinkedStore, 8 | useAsyncLinkedStore, 9 | useAsyncLinkedStoreValue, 10 | useAsyncWithLoaderLinkedStore, 11 | } from './hooks'; 12 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | export enum asyncStatuses { 2 | ready = 'ready', 3 | error = 'error', 4 | pending = 'pending', 5 | } 6 | 7 | export type Trigger = () => void; 8 | 9 | export type GetState = (store: IStore) => State; 10 | export type Getter = (get: GetState) => State; 11 | 12 | export type UpdateState = (state: T) => T; 13 | 14 | export type SetState = (state: State | UpdateState> | void) => void; 15 | 16 | export type GetStateCallback = () => State; 17 | 18 | export interface IBaseStore { 19 | getId(): symbol; 20 | setTrigger(trigger: Trigger): void; 21 | removeTrigger(trigger: Trigger): void; 22 | setDependency(dependencyId: symbol): void; 23 | setDerivedStore(store: IDerivedStore): void; 24 | isAsync(): boolean; 25 | } 26 | 27 | export interface ISimpleStore extends IBaseStore { 28 | getState: GetStateCallback; 29 | setState: SetState; 30 | resetState(): void; 31 | } 32 | 33 | export type State = T extends Promise ? Promise : T; 34 | export type Resource = T extends Promise ? R : T; 35 | 36 | export type ResourceHandler = (store: IDerivedStore) => Resource; 37 | 38 | export interface IDerivedStore extends ISimpleStore { 39 | getStatus(): asyncStatuses; 40 | getResource(): Resource; 41 | } 42 | 43 | export type IStore = ISimpleStore | IDerivedStore; 44 | 45 | export interface IDerivedStores { 46 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 47 | setDerivedStore(store: IDerivedStore): void; 48 | triggerDerivedStores(ids: Set): void; 49 | } 50 | 51 | export type GetStateHookCallback = GetStateCallback>; 52 | 53 | export type AsyncWithLoaderResult = { 54 | isLoading: boolean; 55 | data: Resource; 56 | error: E | null; 57 | }; 58 | -------------------------------------------------------------------------------- /src/stores.ts: -------------------------------------------------------------------------------- 1 | import { IDerivedStore, IDerivedStores } from './models'; 2 | 3 | export class DerivedStores implements IDerivedStores { 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | private derivedStores = new Map>(); 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | public setDerivedStore(store: IDerivedStore): void { 9 | this.derivedStores.set(store.getId(), store); 10 | } 11 | 12 | public triggerDerivedStores(ids: Set): void { 13 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 14 | // @ts-ignore 15 | ids.forEach((id: symbol) => this.derivedStores.get(id).setState()); 16 | } 17 | } 18 | 19 | export const derivedStores = new DerivedStores(); 20 | -------------------------------------------------------------------------------- /src/units/base-unit.ts: -------------------------------------------------------------------------------- 1 | import { derivedStores } from '../stores'; 2 | 3 | import { IBaseStore, IDerivedStore, IDerivedStores, Trigger } from '../models'; 4 | 5 | export class BaseStore implements IBaseStore { 6 | private triggers: Set = new Set(); 7 | private dependencies: Set = new Set(); 8 | private readonly derivedStores: IDerivedStores = derivedStores; 9 | protected isStateAsync = false; 10 | 11 | constructor(private readonly id: symbol) {} 12 | 13 | setDerivedStore(derivedStore: IDerivedStore): void { 14 | this.derivedStores.setDerivedStore(derivedStore); 15 | } 16 | 17 | setTrigger(trigger: Trigger): void { 18 | if (!this.triggers.has(trigger)) { 19 | this.triggers.add(trigger); 20 | } 21 | } 22 | 23 | removeTrigger(trigger: Trigger): void { 24 | this.triggers.delete(trigger); 25 | } 26 | 27 | getId(): symbol { 28 | return this.id; 29 | } 30 | 31 | setDependency(dependencyId: symbol): void { 32 | if (!this.dependencies.has(dependencyId)) { 33 | this.dependencies.add(dependencyId); 34 | } 35 | } 36 | 37 | isAsync(): boolean { 38 | return this.isStateAsync; 39 | } 40 | 41 | protected setAsyncFlag(isStateAsync: boolean): void { 42 | this.isStateAsync = isStateAsync; 43 | } 44 | 45 | protected triggerDependencies(): void { 46 | this.triggerDerivedStates(); 47 | this.fireTriggers(); 48 | } 49 | 50 | private triggerDerivedStates(): void { 51 | this.derivedStores.triggerDerivedStores(this.dependencies); 52 | } 53 | 54 | private fireTriggers(): void { 55 | this.triggers.forEach((trigger) => trigger()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/units/units.ts: -------------------------------------------------------------------------------- 1 | import { BaseStore } from './base-unit'; 2 | 3 | import { 4 | asyncStatuses, 5 | GetState, 6 | Getter, 7 | IDerivedStore, 8 | ISimpleStore, 9 | IStore, 10 | Resource, 11 | State, 12 | UpdateState, 13 | } from '../models'; 14 | 15 | class SimpleStore extends BaseStore implements ISimpleStore { 16 | private state: State; 17 | 18 | constructor(private defaultState: State) { 19 | super(Symbol('store')); 20 | this.state = defaultState; 21 | } 22 | 23 | getState(): State { 24 | return this.state; 25 | } 26 | 27 | setState = (state: State | UpdateState> | void): void => { 28 | if (state === undefined) { 29 | throw new Error('state should be passed'); 30 | } 31 | 32 | const updatedState = this.getUpdatedState(state); 33 | 34 | if (updatedState !== this.state) { 35 | this.state = updatedState; 36 | this.triggerDependencies(); 37 | } 38 | }; 39 | 40 | resetState = (): void => { 41 | this.setState(this.defaultState); 42 | }; 43 | 44 | private getUpdatedState(state: State | UpdateState>): State { 45 | return typeof state === 'function' ? (state as UpdateState>)(this.state) : state; 46 | } 47 | } 48 | 49 | class DerivedStore extends BaseStore implements IDerivedStore { 50 | private readonly defaultState: State; 51 | private state: State; 52 | private actualState: State | null = null; 53 | 54 | private status: asyncStatuses = asyncStatuses.pending; 55 | private resource: Resource = null as Resource; 56 | 57 | constructor(private getter: Getter) { 58 | super(Symbol('derived-store')); 59 | 60 | const state = this.getter(this.get); 61 | this.setAsyncFlag(state instanceof Promise); 62 | 63 | this.setAsyncStatus(); 64 | const adaptedState = this.getAdaptedState(state); 65 | this.defaultState = adaptedState; 66 | this.state = adaptedState; 67 | 68 | this.setDerivedStore(this); 69 | } 70 | 71 | getState(): State { 72 | return this.state; 73 | } 74 | 75 | setState = (): void => { 76 | this.updateState(this.getter(this.get)); 77 | }; 78 | 79 | resetState = (): void => { 80 | this.updateState(this.defaultState); 81 | }; 82 | 83 | getStatus(): asyncStatuses { 84 | return this.status; 85 | } 86 | 87 | getResource(): Resource { 88 | return this.resource; 89 | } 90 | 91 | private get: GetState = (store: IStore): State => { 92 | store.setDependency(this.getId()); 93 | return store.getState(); 94 | }; 95 | 96 | private updateState(updatedState: State): void { 97 | if (updatedState !== this.state) { 98 | this.setAsyncStatus(); 99 | this.setAdaptedState(this.getAdaptedState(updatedState)); 100 | this.triggerDependencies(); 101 | } 102 | } 103 | 104 | private setAdaptedState(adaptedState: State): void { 105 | this.state = adaptedState; 106 | } 107 | 108 | private setAsyncStatus() { 109 | this.status = this.isStateAsync ? asyncStatuses.pending : asyncStatuses.ready; 110 | } 111 | 112 | private getAdaptedState(updatedState: State): State { 113 | if (this.isStateAsync) { 114 | return this.integrateAsyncState(updatedState); 115 | } 116 | 117 | return updatedState; 118 | } 119 | 120 | private integrateAsyncState(state: State): State { 121 | this.actualState = state; 122 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 123 | return (state as any) 124 | .then(this.performCBAndNext(this.setAsyncResource(asyncStatuses.ready), state)) 125 | .catch(this.performCBAndNext(this.setAsyncResource(asyncStatuses.error), state)) 126 | .then(this.performCBAndNext(this.triggerDependencies.bind(this), state)) as State; 127 | } 128 | 129 | private performCBAndNext = (cb: (resource: Resource) => void, state: State) => ( 130 | resource: Resource 131 | ): Resource => { 132 | if (this.actualState === state) { 133 | cb(resource); 134 | } 135 | 136 | return resource; 137 | }; 138 | 139 | private setAsyncResource = (status: asyncStatuses) => (resource: Resource): void => { 140 | this.resource = resource; 141 | this.status = status; 142 | }; 143 | } 144 | 145 | export const simpleStore = (state: State): IStore => new SimpleStore(state); 146 | export const derivedStore = (getter: Getter): IDerivedStore => new DerivedStore(getter); 147 | -------------------------------------------------------------------------------- /src/utils/__mocks__/async-hook.helper.ts: -------------------------------------------------------------------------------- 1 | const resource = 'resource'; 2 | 3 | export const getAsyncResource = jest 4 | .fn() 5 | .mockName('getAsyncResource') 6 | .mockReturnValue(() => resource); 7 | -------------------------------------------------------------------------------- /src/utils/__mocks__/use-register-trigger.hook.ts: -------------------------------------------------------------------------------- 1 | export const useRegisterTrigger = jest.fn().mockName('useRegisterTrigger'); 2 | -------------------------------------------------------------------------------- /src/utils/async-hook.helper.ts: -------------------------------------------------------------------------------- 1 | import { asyncStatuses, IDerivedStore, Resource, ResourceHandler } from '../models'; 2 | import { GetStateCallback } from '../../types'; 3 | 4 | export const getResolvedResource = (store: IDerivedStore): Resource => store.getResource(); 5 | export const throwPendingState = (store: IDerivedStore): never => { 6 | throw store.getState(); 7 | }; 8 | export const throwError = (store: IDerivedStore): never => { 9 | throw store.getResource(); 10 | }; 11 | 12 | export type ResourceHandlers = { 13 | [status in asyncStatuses]: ResourceHandler; 14 | }; 15 | export const resourceHandlers: ResourceHandlers = { 16 | [asyncStatuses.ready]: getResolvedResource, 17 | [asyncStatuses.pending]: throwPendingState, 18 | [asyncStatuses.error]: throwError, 19 | }; 20 | 21 | export const getAsyncResource = (store: IDerivedStore): GetStateCallback> => () => 22 | resourceHandlers[store.getStatus()](store); 23 | -------------------------------------------------------------------------------- /src/utils/use-register-trigger.hook.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { useEffect, useState } = require('react'); 3 | 4 | import { IStore } from '../models'; 5 | 6 | export const useRegisterTrigger = (store: IStore): void => { 7 | const [, setState] = useState({}); 8 | 9 | useEffect(() => { 10 | const trigger = () => setState({}); 11 | store.setTrigger(trigger); 12 | 13 | return (): void => store.removeTrigger(trigger); 14 | }, [store, setState]); 15 | }; 16 | -------------------------------------------------------------------------------- /tests/hooks.spec.ts: -------------------------------------------------------------------------------- 1 | jest.mock('src/utils/async-hook.helper'); 2 | jest.mock('src/utils/use-register-trigger.hook'); 3 | 4 | import { getAsyncResource } from 'src/utils/async-hook.helper'; 5 | import { useRegisterTrigger } from 'src/utils/use-register-trigger.hook'; 6 | 7 | import { asyncStatuses } from '../src/models'; 8 | 9 | import { 10 | useLinkedStoreValue, 11 | useAsyncLinkedStoreValue, 12 | useSetLinkedStore, 13 | useResetLinkedStore, 14 | useLinkedStore, 15 | useAsyncLinkedStore, 16 | useAsyncWithLoaderLinkedStore, 17 | } from 'src/hooks'; 18 | 19 | let state = 'state'; 20 | let resource = 'resource'; 21 | 22 | let getFormedStore = () => { 23 | let store: any = {}; 24 | 25 | beforeEach(() => { 26 | store.isAsync = jest.fn().mockName('getState'); 27 | store.getState = jest.fn().mockName('getState').mockReturnValue(state); 28 | store.settState = jest.fn().mockName('setState'); 29 | store.resetState = jest.fn().mockName('resetState'); 30 | store.getStatus = jest.fn().mockName('getStatus'); 31 | store.getResource = jest.fn().mockName('getResource').mockReturnValue(resource); 32 | }); 33 | 34 | return store; 35 | }; 36 | describe('useLinkedStoreValue', () => { 37 | let store = getFormedStore(); 38 | 39 | it('should use custom hook to register trigger for passed store', () => { 40 | useLinkedStoreValue(store); 41 | expect(useRegisterTrigger).toHaveBeenCalledWith(store); 42 | }); 43 | 44 | it('should return list of state and state getter returns state', () => { 45 | store.isAsync.mockReturnValue(false); 46 | 47 | let [stateValue, getState] = useLinkedStoreValue(store); 48 | 49 | expect(store.isAsync).toHaveBeenCalled(); 50 | expect(store.getState).toHaveBeenCalled(); 51 | 52 | expect(stateValue).toBe(state); 53 | expect(getState()).toBe(state); 54 | }); 55 | 56 | it('*async case* should return list of state and state getter with getter returns resource', () => { 57 | store.isAsync.mockReturnValue(true); 58 | 59 | let [stateValue, getState] = useLinkedStoreValue(store); 60 | 61 | expect(store.isAsync).toHaveBeenCalled(); 62 | expect(store.getState).toHaveBeenCalled(); 63 | 64 | expect(stateValue).toBe(state); 65 | expect(getState()).toBe(resource); 66 | }); 67 | }); 68 | 69 | describe('useAsyncLinkedStoreValue', () => { 70 | let store: any = 'store'; 71 | 72 | it('should use custom hook to register trigger for passed store', () => { 73 | useAsyncLinkedStoreValue(store); 74 | expect(useRegisterTrigger).toHaveBeenCalledWith(store); 75 | }); 76 | 77 | it('should return async resource of passed store', () => { 78 | expect(useAsyncLinkedStoreValue(store)).toBe(resource); 79 | expect(getAsyncResource).toHaveBeenCalledWith(store); 80 | }); 81 | }); 82 | 83 | describe('#useSetLinkedStore', () => { 84 | let store = getFormedStore(); 85 | 86 | it('should return setState of passed store', () => { 87 | expect(useSetLinkedStore(store)).toBe(store.setState); 88 | }); 89 | }); 90 | 91 | describe('#useResetLinkedStore', () => { 92 | let store = getFormedStore(); 93 | 94 | it('should return resetState of passed store', () => { 95 | expect(useResetLinkedStore(store)).toBe(store.resetState); 96 | }); 97 | }); 98 | 99 | describe('#useLinkedStore', () => { 100 | let store = getFormedStore(); 101 | 102 | it('should return list of: state, setState, getState', () => { 103 | const [stateValue, setState, getState] = useLinkedStore(store); 104 | 105 | expect(stateValue).toBe(state); 106 | expect(setState).toBe(store.setState); 107 | expect(getState()).toBe(state); 108 | }); 109 | }); 110 | 111 | describe('#useAsyncLinkedStore', () => { 112 | let store = getFormedStore(); 113 | 114 | it('should return list of: state, setState', () => { 115 | const [stateValue, setState] = useAsyncLinkedStore(store); 116 | expect(stateValue).toBe(resource); 117 | expect(setState).toBe(store.setState); 118 | }); 119 | }); 120 | 121 | describe('#useAsyncWithLoaderLinkedStore', () => { 122 | let store = getFormedStore(); 123 | 124 | it('should return { error, isLoading, data }', () => { 125 | store.getStatus.mockReturnValue(asyncStatuses.ready); 126 | const { error, isLoading, data } = useAsyncWithLoaderLinkedStore(store); 127 | 128 | expect(error).toBeNull(); 129 | expect(isLoading).toBeFalsy(); 130 | expect(data).toBe(resource); 131 | }); 132 | 133 | it('should return isLoading if async state is not fulfilled', () => { 134 | store.getStatus.mockReturnValue(asyncStatuses.pending); 135 | const { error, isLoading } = useAsyncWithLoaderLinkedStore(store); 136 | 137 | expect(error).toBeNull(); 138 | expect(isLoading).toBeTruthy(); 139 | }); 140 | 141 | it('should return error if async state is rejected', () => { 142 | store.getStatus.mockReturnValue(asyncStatuses.error); 143 | const { error, isLoading, data } = useAsyncWithLoaderLinkedStore(store); 144 | 145 | expect(error).toBe(resource); 146 | expect(isLoading).toBeFalsy(); 147 | expect(data).toBeNull(); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /tests/stores.spec.ts: -------------------------------------------------------------------------------- 1 | import { DerivedStores } from 'src/stores'; 2 | 3 | describe('DerivedStores', () => { 4 | let derivedStores: any; 5 | 6 | let id1 = 'id1'; 7 | let id2 = 'id2'; 8 | let id3 = 'id3'; 9 | 10 | let store1: any; 11 | let store2: any; 12 | let store3: any; 13 | 14 | beforeEach(() => { 15 | store1 = { 16 | setState: jest.fn().mockName('setState'), 17 | getId: jest.fn().mockName('getId').mockReturnValue(id1), 18 | }; 19 | store2 = { 20 | setState: jest.fn().mockName('setState'), 21 | getId: jest.fn().mockName('getId').mockReturnValue(id2), 22 | }; 23 | store3 = { 24 | setState: jest.fn().mockName('setState'), 25 | getId: jest.fn().mockName('getId').mockReturnValue(id3), 26 | }; 27 | }); 28 | 29 | beforeEach(() => { 30 | derivedStores = new DerivedStores(); 31 | }); 32 | 33 | describe('#setDerivedStore', () => { 34 | it('should set passed store to map of saved stores and use store id as its key', () => { 35 | derivedStores.setDerivedStore(store1); 36 | expect(derivedStores.derivedStores.size).toBe(1); 37 | expect(derivedStores.derivedStores.get(id1)).toBe(store1); 38 | expect(store1.getId).toHaveBeenCalled(); 39 | }); 40 | }); 41 | 42 | describe('#triggerDerivedStores', () => { 43 | const ids = [id1, id2]; 44 | 45 | beforeEach(() => { 46 | derivedStores.setDerivedStore(store1); 47 | derivedStores.setDerivedStore(store2); 48 | derivedStores.setDerivedStore(store3); 49 | }); 50 | 51 | it('should set state for all stored stores for passed ids', () => { 52 | expect(derivedStores.derivedStores.size).toBe(3); 53 | 54 | derivedStores.triggerDerivedStores(ids); 55 | 56 | expect(store1.setState).toHaveBeenCalled(); 57 | expect(store2.setState).toHaveBeenCalled(); 58 | expect(store3.setState).not.toHaveBeenCalled(); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/units/base-unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { BaseStore } from 'src/units/base-unit'; 2 | import { derivedStores } from 'src/stores'; 3 | 4 | describe('BaseStore', () => { 5 | let store: any; 6 | let id = Symbol('id'); 7 | let trigger: any = 'trigger'; 8 | 9 | beforeEach(() => { 10 | store = new BaseStore(id); 11 | }); 12 | 13 | it('#init state', () => { 14 | expect(store.triggers.size).toBe(0); 15 | expect(store.dependencies.size).toBe(0); 16 | expect(store.derivedStores).toBe(derivedStores); 17 | expect(store.isStateAsync).toBeFalsy(); 18 | }); 19 | 20 | describe('#setDerivedStore', () => { 21 | beforeEach(() => { 22 | store.derivedStores = { 23 | setDerivedStore: jest.fn().mockName('setDerivedStore'), 24 | }; 25 | }); 26 | 27 | it('should set passed derived store to derived stores', () => { 28 | let derivedStore = 'derivedStore'; 29 | store.setDerivedStore(derivedStore); 30 | expect(store.derivedStores.setDerivedStore).toHaveBeenCalledWith(derivedStore); 31 | }); 32 | }); 33 | 34 | describe('#setTrigger', () => { 35 | it('should set passed trigger it if does not exist', () => { 36 | store.setTrigger(trigger); 37 | expect(store.triggers.has(trigger)).toBeTruthy(); 38 | expect(store.triggers.size).toBe(1); 39 | 40 | store.setTrigger(trigger); 41 | expect(store.triggers.size).toBe(1); 42 | }); 43 | }); 44 | 45 | describe('#removeTrigger', () => { 46 | it('should remove passed trigger from triggers', () => { 47 | store.setTrigger(trigger); 48 | expect(store.triggers.size).toBe(1); 49 | 50 | store.removeTrigger(trigger); 51 | expect(store.triggers.size).toBe(0); 52 | }); 53 | }); 54 | 55 | describe('#getId', () => { 56 | it('should return store id', () => { 57 | expect(store.getId()).toBe(id); 58 | }); 59 | }); 60 | 61 | describe('#setDependency', () => { 62 | let dependencyId = Symbol('dependencyId'); 63 | 64 | it('should set passed dependency id it if does not exist', () => { 65 | store.setDependency(dependencyId); 66 | expect(store.dependencies.has(dependencyId)).toBeTruthy(); 67 | expect(store.dependencies.size).toBe(1); 68 | 69 | store.setDependency(dependencyId); 70 | expect(store.dependencies.size).toBe(1); 71 | }); 72 | }); 73 | 74 | describe('#isAsync', () => { 75 | it('should return true if state is async', () => { 76 | store.isStateAsync = true; 77 | expect(store.isAsync()).toBeTruthy(); 78 | }); 79 | 80 | it('should return false if state is sync', () => { 81 | store.isStateAsync = false; 82 | expect(store.isAsync()).toBeFalsy(); 83 | }); 84 | }); 85 | 86 | describe('#setAsyncFlag', () => { 87 | let isStateAsync: any = 'isStateAsync'; 88 | 89 | it('should set async status of state', () => { 90 | store.setAsyncFlag(isStateAsync); 91 | expect(store.isStateAsync).toBe(isStateAsync); 92 | }); 93 | }); 94 | 95 | describe('#triggerDependencies', () => { 96 | beforeEach(() => { 97 | store.triggerDerivedStates = jest.fn().mockName('triggerDerivedStates'); 98 | store.fireTriggers = jest.fn().mockName('fireTriggers'); 99 | }); 100 | 101 | it('should trigger dependent derived states and fire dependent triggers', () => { 102 | store.triggerDependencies(); 103 | expect(store.triggerDerivedStates).toHaveBeenCalled(); 104 | expect(store.fireTriggers).toHaveBeenCalled(); 105 | }); 106 | }); 107 | 108 | describe('#triggerDerivedStates', () => { 109 | let dependencies = 'dependencies'; 110 | 111 | beforeEach(() => { 112 | store.derivedStores.triggerDerivedStores = jest.fn().mockName('triggerDerivedStores'); 113 | }); 114 | 115 | it('should trigger dependent derived stores', () => { 116 | store.dependencies = dependencies; 117 | store.triggerDerivedStates(); 118 | expect(store.derivedStores.triggerDerivedStores).toHaveBeenCalledWith(dependencies); 119 | }); 120 | }); 121 | 122 | describe('#fireTriggers', () => { 123 | let trigger1: any; 124 | let trigger2: any; 125 | 126 | beforeEach(() => { 127 | trigger1 = jest.fn().mockName('trigger1'); 128 | trigger2 = jest.fn().mockName('trigger2'); 129 | 130 | store.triggers.add(trigger1); 131 | store.triggers.add(trigger2); 132 | }); 133 | 134 | it('should fire each storied triggers', () => { 135 | store.fireTriggers(); 136 | expect(trigger1).toHaveBeenCalled(); 137 | expect(trigger2).toHaveBeenCalled(); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /tests/units/units.spec.ts: -------------------------------------------------------------------------------- 1 | import { simpleStore, derivedStore } from 'src/units/units'; 2 | import { BaseStore } from '../../src/units/base-unit'; 3 | 4 | import { asyncStatuses } from '../../src/models'; 5 | 6 | let state = 'state'; 7 | let updatedState = 'updatedState'; 8 | let newState = 'newState'; 9 | let resource = 'resource'; 10 | 11 | describe('simpleStore', () => { 12 | let store: any; 13 | 14 | beforeEach(() => { 15 | store = simpleStore(state); 16 | }); 17 | 18 | it('#init state', () => { 19 | expect(store instanceof BaseStore).toBeTruthy(); 20 | expect(store.state).toBe(state); 21 | expect(store.defaultState).toBe(state); 22 | }); 23 | 24 | describe('#getState', () => { 25 | it('should return actual state', () => { 26 | expect(store.getState()).toBe(state); 27 | }); 28 | }); 29 | 30 | describe('#setState', () => { 31 | beforeEach(() => { 32 | store.triggerDependencies = jest.fn().mockName('triggerDependencies'); 33 | store.getUpdatedState = jest 34 | .fn() 35 | .mockName('updatedState') 36 | .mockReturnValue(updatedState); 37 | }); 38 | 39 | it('should throw error is state is not passed', () => { 40 | let error: any; 41 | 42 | try { 43 | store.setState(); 44 | } catch (e) { 45 | error = e; 46 | } 47 | 48 | expect(error.message).toBe('state should be passed'); 49 | }); 50 | 51 | it('should set updated state and trigger dependencies', () => { 52 | store.setState(newState); 53 | 54 | expect(store.getUpdatedState).toHaveBeenCalledWith(newState); 55 | expect((store.state = updatedState)); 56 | expect(store.triggerDependencies).toHaveBeenCalled(); 57 | }); 58 | 59 | it('should not update state and trigger dependencies is updates state is the same as actual state', () => { 60 | store.state = updatedState; 61 | store.setState(newState); 62 | 63 | expect(store.getUpdatedState).toHaveBeenCalledWith(newState); 64 | expect(store.triggerDependencies).not.toHaveBeenCalled(); 65 | }); 66 | }); 67 | 68 | describe('#resetState', () => { 69 | it('should reset state to default value', () => { 70 | store.state = updatedState; 71 | store.resetState(); 72 | expect(store.state).toBe(state); 73 | }); 74 | }); 75 | 76 | describe('#getUpdatedState', () => { 77 | it('should return state if passed argument is not function', () => { 78 | expect(store.getUpdatedState(updatedState)).toBe(updatedState); 79 | }); 80 | 81 | it('should call passed callback with actual state and return its result', () => { 82 | expect(store.getUpdatedState((state: any) => state + updatedState)).toBe( 83 | state + updatedState 84 | ); 85 | }); 86 | }); 87 | }); 88 | 89 | describe('derivedStore', () => { 90 | let store: any; 91 | let sStore = simpleStore(state); 92 | 93 | beforeEach(() => { 94 | store = derivedStore((get) => get(sStore)); 95 | }); 96 | 97 | it('#init state', () => { 98 | expect(store instanceof BaseStore).toBeTruthy(); 99 | expect(store.state).toBe(state); 100 | expect(store.defaultState).toBe(state); 101 | 102 | expect(store.actualState).toBeNull(); 103 | expect(store.resource).toBeNull(); 104 | expect(store.status).toBe(asyncStatuses.ready); 105 | }); 106 | 107 | describe('#getState', () => { 108 | it('should return actual state', () => { 109 | expect(store.getState()).toBe(state); 110 | }); 111 | }); 112 | 113 | describe('#setState', () => { 114 | let getter = 'getter'; 115 | beforeEach(() => { 116 | store.updateState = jest.fn().mockName('updateState'); 117 | store.getter = jest.fn().mockName('getter').mockReturnValue(getter); 118 | }); 119 | 120 | it('should call update state with getter', () => { 121 | store.setState(); 122 | expect(store.updateState).toHaveBeenCalledWith(getter); 123 | expect(store.getter).toHaveBeenCalledWith(store.get); 124 | }); 125 | }); 126 | 127 | describe('#resetState', () => { 128 | let defaultState = 'defaultState'; 129 | 130 | beforeEach(() => { 131 | store.defaultState = defaultState; 132 | store.updateState = jest.fn().mockName('updateState'); 133 | }); 134 | 135 | it('should call #updateState with default state', () => { 136 | store.resetState(); 137 | expect(store.updateState).toHaveBeenCalledWith(store.defaultState); 138 | }); 139 | }); 140 | 141 | describe('#getStatus', () => { 142 | it('should return actual state status', () => { 143 | expect(store.getStatus()).toBe(asyncStatuses.ready); 144 | }); 145 | }); 146 | 147 | describe('#getResource', () => { 148 | it('should return actual resource', () => { 149 | store.resource = resource; 150 | expect(store.getResource()).toBe(resource); 151 | }); 152 | }); 153 | 154 | describe('#get', () => { 155 | let passedStore: any = {}; 156 | let passedStoreState = 'passedStoreState'; 157 | 158 | beforeEach(() => { 159 | passedStore.setDependency = jest.fn().mockName('setDependency'); 160 | passedStore.getState = jest.fn().mockName('getState').mockReturnValue(passedStoreState); 161 | }); 162 | 163 | it('should register store as dependency of passed store to fire it on update', () => { 164 | store.get(passedStore); 165 | expect(passedStore.setDependency).toHaveBeenCalledWith(store.getId()); 166 | }); 167 | 168 | it('should return state of passed store', () => { 169 | expect(store.get(passedStore)).toBe(passedStoreState); 170 | }); 171 | }); 172 | 173 | describe('#updateState', () => { 174 | beforeEach(() => { 175 | store.setAdaptedState = jest.fn().mockName('setAdaptedState'); 176 | store.triggerDependencies = jest.fn().mockName('triggerDependencies'); 177 | store.getAdaptedState = jest 178 | .fn() 179 | .mockName('getAdaptedState') 180 | .mockReturnValue(updatedState); 181 | }); 182 | 183 | it('should do nothing if passed state the same with actual state', () => { 184 | store.updateState(state); 185 | expect(store.setAdaptedState).not.toHaveBeenCalled(); 186 | expect(store.triggerDependencies).not.toHaveBeenCalled(); 187 | }); 188 | 189 | it('should adapt state and set it and then trigger dependencies', () => { 190 | store.updateState(newState); 191 | expect(store.getAdaptedState).toHaveBeenCalledWith(newState); 192 | expect(store.setAdaptedState).toHaveBeenCalledWith(updatedState); 193 | expect(store.triggerDependencies).toHaveBeenCalled(); 194 | }); 195 | }); 196 | 197 | describe('#setAdaptedState', () => { 198 | it('should set state', () => { 199 | store.setAdaptedState(updatedState); 200 | expect(store.state).toBe(updatedState); 201 | }); 202 | }); 203 | 204 | describe('#setAsyncStatus', () => { 205 | it('should set status as pending for async state', () => { 206 | store.isStateAsync = true; 207 | store.setAsyncStatus(); 208 | expect(store.status).toBe(asyncStatuses.pending); 209 | }); 210 | 211 | it('should set status as ready for sync state', () => { 212 | store.isStateAsync = false; 213 | store.setAsyncStatus(); 214 | expect(store.status).toBe(asyncStatuses.ready); 215 | }); 216 | }); 217 | 218 | describe('#getAdaptedState', () => { 219 | beforeEach(() => { 220 | store.integrateAsyncState = jest 221 | .fn() 222 | .mockName('integrateAsyncState') 223 | .mockReturnValue(updatedState); 224 | }); 225 | 226 | it('should return passed state is is is not async', () => { 227 | expect(store.getAdaptedState(newState)).toBe(newState); 228 | expect(store.integrateAsyncState).not.toHaveBeenCalled(); 229 | }); 230 | 231 | it('should return state with additional setters of status and resource for async state', () => { 232 | store.isStateAsync = true; 233 | expect(store.getAdaptedState(newState)).toBe(updatedState); 234 | expect(store.integrateAsyncState).toHaveBeenCalledWith(newState); 235 | }); 236 | }); 237 | 238 | describe('#integrateAsyncState', () => { 239 | let asyncState: any; 240 | 241 | beforeEach(() => { 242 | asyncState = Promise.resolve(resource); 243 | store.triggerDependencies = jest.fn().mockName('triggerDependencies'); 244 | store.setAsyncResource = jest 245 | .fn() 246 | .mockName('integrateAsyncState') 247 | .mockImplementation((status: any) => status); 248 | store.performCBAndNext = jest 249 | .fn() 250 | .mockName('performCBAndNext') 251 | .mockImplementation(() => (resource: any) => resource); 252 | }); 253 | 254 | it('should set passed state as actual', () => { 255 | store.integrateAsyncState(asyncState); 256 | expect(store.actualState).toBe(asyncState); 257 | }); 258 | 259 | it('should return wrapped promise with the same resolved values', async () => { 260 | expect(await store.integrateAsyncState(asyncState)).toBe(resource); 261 | }); 262 | 263 | it('should add middlewares to async state to update status and resource', async () => { 264 | await store.integrateAsyncState(asyncState); 265 | 266 | expect(store.setAsyncResource).toHaveBeenCalledWith(asyncStatuses.ready); 267 | expect(store.performCBAndNext).toHaveBeenCalledWith(asyncStatuses.ready, asyncState); 268 | expect(store.performCBAndNext).toHaveBeenCalledWith(asyncStatuses.error, asyncState); 269 | 270 | let [ 271 | , 272 | , 273 | [boundTriggerDependencies, passedAsyncState], 274 | ] = store.performCBAndNext.mock.calls; 275 | expect(store.triggerDependencies).not.toHaveBeenCalled(); 276 | boundTriggerDependencies(); 277 | expect(store.triggerDependencies).toHaveBeenCalled(); 278 | expect(passedAsyncState).toBe(asyncState); 279 | }); 280 | }); 281 | 282 | describe('#performCBAndNext', () => { 283 | let callback: any; 284 | 285 | beforeEach(() => { 286 | callback = jest.fn().mockName('callback'); 287 | }); 288 | 289 | it('should return callback call of which will return passed argument', () => { 290 | expect(store.performCBAndNext()(resource)).toBe(resource); 291 | }); 292 | 293 | it('returned function should call closure callback with passed resource if passed state is still actual', () => { 294 | store.actualState = updatedState; 295 | let fn = store.performCBAndNext(callback, updatedState); 296 | 297 | expect(callback).not.toHaveBeenCalled(); 298 | 299 | fn(resource); 300 | expect(callback).toHaveBeenCalledWith(resource); 301 | }); 302 | 303 | it('returned function should not call closure callback if passed state is not actual', () => { 304 | store.actualState = updatedState; 305 | let fn = store.performCBAndNext(callback, state); 306 | 307 | fn(resource); 308 | expect(callback).not.toHaveBeenCalled(); 309 | }); 310 | }); 311 | 312 | describe('#setAsyncResource', () => { 313 | it('should return callback which will set passed status and resource', () => { 314 | store.status = asyncStatuses.pending; 315 | let callback = store.setAsyncResource(asyncStatuses.ready); 316 | 317 | expect(store.status).not.toBe(asyncStatuses.ready); 318 | expect(store.resource).toBeNull(); 319 | 320 | callback(resource); 321 | 322 | expect(store.status).toBe(asyncStatuses.ready); 323 | expect(store.resource).toBe(resource); 324 | }); 325 | }); 326 | }); 327 | -------------------------------------------------------------------------------- /tests/utils/async-hook.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAsyncResource, 3 | getResolvedResource, 4 | throwError, 5 | throwPendingState, 6 | } from 'src/utils/async-hook.helper'; 7 | 8 | import { asyncStatuses } from 'src/models'; 9 | 10 | const getCaughtError = (cb: any) => { 11 | try { 12 | cb(); 13 | } catch (error) { 14 | return error; 15 | } 16 | }; 17 | 18 | describe('async hook helper', () => { 19 | let store: any = {}; 20 | let pendingState = 'pendingState'; 21 | let resource = 'resource'; 22 | 23 | beforeEach(() => { 24 | store.getState = jest.fn().mockName('getState').mockReturnValue(pendingState); 25 | store.getResource = jest.fn().mockName('getResource').mockReturnValue(resource); 26 | store.getStatus = jest 27 | .fn() 28 | .mockName('getStatus') 29 | .mockImplementation(() => store.status); 30 | }); 31 | 32 | describe('#getAsyncResource', () => { 33 | it('should return resolved resource if store status is ready', () => { 34 | store.status = asyncStatuses.ready; 35 | expect(getAsyncResource(store)()).toBe(resource); 36 | }); 37 | 38 | it('should throw error with pending state is state is in pending status', () => { 39 | store.status = asyncStatuses.pending; 40 | expect(getCaughtError(getAsyncResource(store))).toBe(pendingState); 41 | }); 42 | 43 | it('should throw error if async state was rejected', () => { 44 | store.status = asyncStatuses.error; 45 | expect(getCaughtError(getAsyncResource(store))).toBe(resource); 46 | }); 47 | }); 48 | 49 | describe('#getResolvedResource', () => { 50 | it('should return resolved resource of async store', () => { 51 | expect(getResolvedResource(store)).toBe(resource); 52 | }); 53 | }); 54 | 55 | describe('#throwError', () => { 56 | it('should throw error if async store resource was rejected', () => { 57 | expect(getCaughtError(() => throwError(store))).toBe(resource); 58 | }); 59 | }); 60 | 61 | describe('#throwPendingState', () => { 62 | it('should throw pending sate if async store is in pending status', () => { 63 | expect(getCaughtError(() => throwPendingState(store))).toBe(pendingState); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/utils/use-register-trigger.hook.spec.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useRegisterTrigger } from 'src/utils/use-register-trigger.hook'; 3 | 4 | describe('useRegisterTrigger', () => { 5 | let store: any = {}; 6 | let setState: any; 7 | 8 | beforeEach(() => { 9 | setState = jest.fn().mockName('setState'); 10 | (useState as any).mockReturnValue([, setState]); 11 | }); 12 | 13 | beforeEach(() => { 14 | store.setTrigger = jest.fn().mockName('setTrigger'); 15 | store.removeTrigger = jest.fn().mockName('removeTrigger'); 16 | }); 17 | 18 | it('should call #useState to use its setter as trigger and #useEffect to set the trigger to store', () => { 19 | useRegisterTrigger(store); 20 | 21 | expect(useState).toHaveBeenCalledWith({}); 22 | expect(useEffect).toHaveBeenCalled(); 23 | }); 24 | 25 | describe('#useEffect', () => { 26 | let useEffectCallback: any; 27 | let useEffectDeps: any; 28 | beforeEach(() => { 29 | (useEffect as any).mockImplementation((cb: any, deps: any[]) => { 30 | useEffectCallback = cb; 31 | useEffectDeps = deps; 32 | }); 33 | }); 34 | 35 | beforeEach(() => { 36 | useRegisterTrigger(store); 37 | }); 38 | 39 | it('should update #useEffect only on store or setState update (on mount any component only)', () => { 40 | expect(useEffectDeps).toEqual([store, setState]); 41 | }); 42 | 43 | it('should wrap setState to trigger and set it to the passed store', () => { 44 | useEffectCallback(); 45 | let [[trigger]] = store.setTrigger.mock.calls; 46 | expect(store.setTrigger).toHaveBeenCalledWith(trigger); 47 | 48 | expect(setState).not.toHaveBeenCalled(); 49 | 50 | trigger(); 51 | expect(setState).toHaveBeenCalledWith({}); 52 | }); 53 | 54 | it('should should remove trigger on component unmount', () => { 55 | let unmountCallback = useEffectCallback(); 56 | let [[trigger]] = store.setTrigger.mock.calls; 57 | 58 | expect(store.removeTrigger).not.toHaveBeenCalled(); 59 | 60 | unmountCallback(); 61 | expect(store.removeTrigger).toHaveBeenCalledWith(trigger); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react", 17 | "typeRoots": ["node_modules/@types", "./types"], 18 | "baseUrl": "." 19 | }, 20 | "include": ["src", "./types"] 21 | } 22 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export enum asyncStatuses { 2 | ready = 'ready', 3 | error = 'error', 4 | pending = 'pending', 5 | } 6 | 7 | export type Trigger = () => void; 8 | 9 | export type GetState = (store: IStore) => T; 10 | export type Getter = (get: GetState) => T; 11 | 12 | export type UpdateState = (state: T) => T; 13 | 14 | export type SetState = (state: T | UpdateState | void) => void; 15 | 16 | export type GetStateCallback = () => T; 17 | export type GetStateHookCallback = GetStateCallback ? Resource : T>; 18 | 19 | export type Resource = T extends Promise ? R : T; 20 | 21 | export interface IBaseStore { 22 | getId(): symbol; 23 | setTrigger(trigger: Trigger): void; 24 | removeTrigger(trigger: Trigger): void; 25 | setDependency(dependencyId: symbol): void; 26 | setDerivedStore(store: IDerivedStore): void; 27 | isAsync(): boolean; 28 | } 29 | 30 | export interface ISimpleStore extends IBaseStore { 31 | getState(): T; 32 | setState: SetState; 33 | resetState(): void; 34 | } 35 | 36 | export interface IDerivedStore extends ISimpleStore { 37 | getStatus(): asyncStatuses; 38 | getResource(): Resource; 39 | } 40 | 41 | export type IStore = ISimpleStore | IDerivedStore; 42 | 43 | export function simpleStore(state: T): IStore; 44 | export function derivedStore(get: Getter): IDerivedStore; 45 | 46 | export function getAsyncResource(store: IDerivedStore): GetStateCallback>; 47 | 48 | export function useLinkedStoreValue(store: IStore): [T, GetStateHookCallback]; 49 | export function useSetLinkedStore(store: IStore): SetState; 50 | export function useResetLinkedStore(store: IStore): () => void; 51 | export function useLinkedStore(store: IStore): [T, SetState, GetStateHookCallback]; 52 | 53 | export function useAsyncLinkedStoreValue(store: IDerivedStore): Resource; 54 | export function useAsyncLinkedStore(store: IDerivedStore): [Resource, SetState]; 55 | 56 | export type AsyncWithLoaderResult = { 57 | isLoading: boolean; 58 | data: Resource; 59 | error: E | null; 60 | }; 61 | 62 | export function useAsyncWithLoaderLinkedStore( 63 | store: IDerivedStore 64 | ): AsyncWithLoaderResult; 65 | --------------------------------------------------------------------------------