├── .gitignore ├── index.d.ts ├── package-lock.json ├── package.json ├── readme.md ├── src ├── __tests__ │ ├── helpers.js │ └── simpleState.js └── index.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | interface Set {} 2 | 3 | export interface IStateAccessor { 4 | (): any; 5 | (value: any): void; 6 | hasChange(): boolean; 7 | resetOriginalValue(): void; 8 | state: IState; 9 | } 10 | 11 | export interface IStateMap { 12 | [key: string]: IState; 13 | } 14 | 15 | export interface IAction { 16 | (...args: any[]): any; 17 | getStates(): IState[]; 18 | setStates(states: IState[]): void; 19 | } 20 | 21 | export interface IStateOptions { 22 | defaultValue?: any; 23 | debounce?: number; 24 | } 25 | 26 | export interface ISubscriber { 27 | (): void; 28 | } 29 | 30 | export interface IState { 31 | value: any; 32 | done; 33 | error?: any; 34 | async: boolean; 35 | lock?: any; 36 | subscribers: Set; 37 | } 38 | 39 | export function createState( 40 | dependencies: IState[], 41 | loader: Function, 42 | options?: IStateOptions 43 | ): IState; 44 | export function createState(defaultValue?: any): IState; 45 | 46 | export function createAction(states: IState[], functor: Function): IAction; 47 | 48 | export function useStates(...states: IState[]); 49 | 50 | export function withAsyncStates(stateMap: IStateMap, fallbackOrOptions); 51 | 52 | export function mock(actionMockings: [IAction, IState[]][], functor: Function); 53 | 54 | export function loadStates(states: IStateMap, data: any); 55 | 56 | export function exportStateValues(states: IStateMap); 57 | 58 | export function persist( 59 | states: IStateMap, 60 | data: any, 61 | onChange: (state: any) => void, 62 | debounce?: number 63 | ); 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hookex", 3 | "version": "0.0.23", 4 | "description": "Incredible fast state manager for React", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "jest --verbose" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/linq2js/hookex.git" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "redux", 16 | "state", 17 | "management", 18 | "state", 19 | "manager", 20 | "state", 21 | "flux" 22 | ], 23 | "author": "linq2js", 24 | "license": "ISC", 25 | "bugs": { 26 | "url": "https://github.com/linq2js/hookex/issues" 27 | }, 28 | "homepage": "https://github.com/linq2js/hookex#readme", 29 | "dependencies": { 30 | "react": "latest" 31 | }, 32 | "babel": { 33 | "presets": [ 34 | "@babel/preset-env" 35 | ] 36 | }, 37 | "jest": { 38 | "transform": { 39 | ".*": "/node_modules/babel-jest" 40 | }, 41 | "unmockedModulePathPatterns": [ 42 | "/node_modules/react", 43 | "/node_modules/react-dom", 44 | "/node_modules/react-addons-test-utils", 45 | "/node_modules/fbjs" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@babel/cli": "^7.4.4", 50 | "@babel/core": "^7.4.5", 51 | "@babel/preset-env": "^7.4.5", 52 | "@types/react": "^16.8.22", 53 | "jest": "^24.8.0", 54 | "babel-core": "^6.26.3", 55 | "babel-jest": "^24.8.0", 56 | "babel-preset-env": "^1.7.0", 57 | "babel-loader": "^8.0.6", 58 | "prettier": "^1.18.2", 59 | "react-dom": "^16.8.6", 60 | "typescript": "^3.3.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # hookex 2 | 3 | A state manager for React without reducer, Provider, dispatcher etc. Design for large projects. 4 | 5 | 1. No Provider needed 6 | 1. No Store needed 7 | 1. No Reducer needed 8 | 1. No Action Creator needed 9 | 1. No Dispatcher needed 10 | 1. Simple concept: State & Action 11 | 1. Support Simple State (Synchronous State) 12 | 1. Support Asynchronous State (with debouncing) 13 | 1. Support Dynamic State 14 | 1. Support Sub State 15 | 1. Built-in methods for updating state on the fly 16 | 1. Compatible with other state mangers (MobX, Redux...) 17 | 18 | # Samples 19 | 20 | ## Counter App 21 | 22 | ```jsx harmony 23 | import React from "react"; 24 | import { render } from "react-dom"; 25 | import { createState, createAction, useStates } from "hookex"; 26 | 27 | // define CountState with 1 as default value 28 | const CountState = createState(1); 29 | // define an action, specified CountState as dependencies 30 | // action body receives CountState accessor 31 | // using count() to get current state value and count(newValue) to update state 32 | const Increase = createAction([CountState], count => count(count() + 1)); 33 | 34 | function App() { 35 | const [count] = useStates(CountState); 36 | return ( 37 |
38 | Counter: {count} 39 | 40 |
41 | ); 42 | } 43 | 44 | render(, document.getElementById("root")); 45 | ``` 46 | 47 | ## Dynamic State 48 | 49 | Create dynamic state which is computed from other states 50 | 51 | ```jsx harmony 52 | import React from "react"; 53 | import { render } from "react-dom"; 54 | import { createState, createAction, useStates } from "hookex"; 55 | 56 | const CountState = createState(1); 57 | const DoubleCountState = createState([CountState], count => count * 2, { sync: true }); 58 | const Increase = createAction([CountState], count => count(count() + 1)); 59 | 60 | function App() { 61 | const [count, doubleCount] = useStates(CountState, DoubleCountState); 62 | return ( 63 |
64 |

Counter: {count}

65 |

Double Counter: {doubleCount}

66 | 67 |
68 | ); 69 | } 70 | 71 | render(, document.getElementById("root")); 72 | ``` 73 | 74 | ## Async State 75 | 76 | Search github user 77 | 78 | ```jsx harmony 79 | import React from "react"; 80 | import { render } from "react-dom"; 81 | import { createState, createAction, useStates } from "hookex"; 82 | 83 | const apiUrl = "https://api.github.com/users/"; 84 | const SearchTermState = createState(""); 85 | const UpdateSearchTerm = createAction([SearchTermState], (searchTerm, value) => 86 | searchTerm(value) 87 | ); 88 | // once searchTerm changed, UserInfo state will be recomputed 89 | const UserInfoState = createState([SearchTermState], async searchTerm => { 90 | const res = await fetch(apiUrl + searchTerm); 91 | return await res.json(); 92 | }); 93 | 94 | function App() { 95 | const [searchTerm, userInfo] = useStates(SearchTermState, UserInfoState); 96 | const { value, done } = userInfo; 97 | return ( 98 |
99 | UpdateSearchTerm(e.target.value)} 103 | /> 104 |
{done ? JSON.stringify(value, null, 2) : "Searching..."}
105 |
106 | ); 107 | } 108 | 109 | render(, document.getElementById("root")); 110 | ``` 111 | 112 | ## Using AsyncRender component 113 | 114 | AsyncRender component receives specified async state (or multiple states). 115 | When state loaded, render callback/component will be called 116 | unless AsyncRender's children will be rendered instead 117 | 118 | ```jsx harmony 119 | import React from "react"; 120 | import { render } from "react-dom"; 121 | import { createState, createAction, useStates, AsyncRender } from "./hookex"; 122 | 123 | const apiUrl = "https://api.github.com/users/"; 124 | const SearchTermState = createState(""); 125 | const UpdateSearchTerm = createAction([SearchTermState], (searchTerm, value) => 126 | searchTerm(value) 127 | ); 128 | const UserInfoState = createState([SearchTermState], async searchTerm => { 129 | const res = await fetch(apiUrl + searchTerm); 130 | return await res.json(); 131 | }); 132 | 133 | function UserInfo({ data }) { 134 | return
{JSON.stringify(data, null, 2)}
; 135 | } 136 | 137 | function App() { 138 | const [searchTerm] = useStates(SearchTermState); 139 | 140 | return ( 141 |
142 |

143 | UpdateSearchTerm(e.target.value)} 147 | /> 148 |

149 | 150 | Loading... 151 | 152 |
153 | ); 154 | } 155 | 156 | render(, document.getElementById("root")); 157 | ``` 158 | 159 | ## Saving and loading states with localStorage 160 | 161 | ```jsx harmony 162 | import { createState, createAction, persist } from "hookex"; 163 | 164 | const CounterState = createState(1); 165 | const Increase = createAction([CounterState], async counter => 166 | console.log(counter(counter() + 1)) 167 | ); 168 | 169 | setInterval(Increase, 3000); 170 | 171 | persist( 172 | { 173 | counter: CounterState 174 | }, 175 | JSON.parse(localStorage.getItem("counterApp")) || {}, 176 | state => localStorage.setItem("counterApp", JSON.stringify(state)) 177 | ); 178 | ``` 179 | 180 | ## Update single state 181 | 182 | Note: Cannot update computed state 183 | 184 | ```jsx harmony 185 | import { createState } from "hookex"; 186 | 187 | const CounterState = createState(1); 188 | 189 | setInterval( 190 | () => 191 | CounterState(prev => { 192 | console.log(prev); 193 | return prev + 1; 194 | }), 195 | 3000 196 | ); 197 | ``` 198 | 199 | ## Using State as event handler 200 | 201 | You can pass state to element event, it can process input synthetic event (event.target.value/event.target.checked) 202 | 203 | ```jsx harmony 204 | import React from "react"; 205 | import { render } from "react-dom"; 206 | import { createState, useStates } from "hookex"; 207 | 208 | const ValueState = createState("Hello world !!!"); 209 | const CheckedState = createState(true); 210 | 211 | function App() { 212 | const [value, checked] = useStates(ValueState, CheckedState); 213 | 214 | return ( 215 | <> 216 |

217 | 218 |

219 |

{value}

220 |

221 | 222 |

223 |

{checked ? "checked" : "unchecked"}

224 | 225 | ); 226 | } 227 | 228 | render(, document.getElementById("root")); 229 | ``` 230 | 231 | # API References 232 | 233 | 1. createState(defaultValue) 234 | 1. createState(dependencies, functor, options) 235 | 1. createAction(dependencies, functor) 236 | 1. useStates(...states) 237 | 1. withAsyncStates(states, fallbackOrOptions) 238 | 1. updateStates(stateMap, data) 239 | 1. AsyncRender 240 | 1. persistStates(stateMap, initialData, onChange) 241 | 1. compose(...funcs) 242 | 1. hoc(functor) 243 | 1. memoize(func) 244 | 1. configure(optionsOrCallback) 245 | 246 | -------------------------------------------------------------------------------- /src/__tests__/helpers.js: -------------------------------------------------------------------------------- 1 | import { createAction, createState } from "../index"; 2 | 3 | test("date.day helper", () => { 4 | const state = createState(new Date("2019-01-01")); 5 | const action = createAction([state], state => state.add(1, "day")); 6 | action(); 7 | expect(state.value.getDate()).toBe(2); 8 | }); 9 | 10 | test("date.year helper", () => { 11 | const state = createState(new Date("2019-01-01")); 12 | const action = createAction([state], state => state.add(2, "year")); 13 | action(); 14 | expect(state.value.getFullYear()).toBe(2021); 15 | }); 16 | 17 | test("boolean helper", () => { 18 | const state = createState(true); 19 | const action = createAction([state], state => state.toggle()); 20 | action(); 21 | expect(state.value).toBe(false); 22 | action(); 23 | expect(state.value).toBe(true); 24 | }); 25 | 26 | test("number helper", () => { 27 | const state = createState(1); 28 | const action = createAction([state], state => state.add(1)); 29 | action(); 30 | expect(state.value).toBe(2); 31 | }); 32 | -------------------------------------------------------------------------------- /src/__tests__/simpleState.js: -------------------------------------------------------------------------------- 1 | import { createAction, createState } from "../index"; 2 | 3 | test("Should read and write data on single state properly", () => { 4 | const state = createState(1); 5 | 6 | expect(state.value).toBe(1); 7 | state(5); 8 | expect(state.value).toBe(5); 9 | }); 10 | 11 | test("Should computed state properly", () => { 12 | const baseValueState = createState(1); 13 | const doubleValueState = createState( 14 | [baseValueState], 15 | baseValue => baseValue * 2, 16 | { sync: true } 17 | ); 18 | expect(doubleValueState.value).toBe(2); 19 | }); 20 | 21 | test("Should do update once only though actions dispatch many time", () => { 22 | const state = createState(2); 23 | const callback = jest.fn(); 24 | state.subscribe(callback); 25 | 26 | const action1 = createAction([], state1 => { 27 | action2(); 28 | action2(); 29 | }); 30 | const action2 = createAction([state], state2 => { 31 | state2(state2() + 1); 32 | }); 33 | 34 | action1(); 35 | 36 | expect(state.value).toBe(4); 37 | expect(callback.mock.calls.length).toBe(1); 38 | }); 39 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, createElement, memo, useRef } from "react"; 2 | const useEffectWithDynamicArray = useEffect; 3 | const scopeUpdates = []; 4 | const noop = () => {}; 5 | const noChange = {}; 6 | const modifyDate = ( 7 | date, 8 | year = 0, 9 | month = 0, 10 | day = 0, 11 | hour = 0, 12 | minute = 0, 13 | second = 0, 14 | milli = 0 15 | ) => 16 | new Date( 17 | date.getFullYear() + year, 18 | date.getMonth() + month, 19 | date.getDate() + day, 20 | date.getHours() + hour, 21 | date.getMinutes() + minute, 22 | date.getSeconds() + second, 23 | date.getMilliseconds() + milli 24 | ); 25 | const cloneObject = obj => (Array.isArray(obj) ? [...obj] : { ...obj }); 26 | const dateModifiers = { 27 | month(date, value) { 28 | return modifyDate(date, 0, value); 29 | }, 30 | year(date, value) { 31 | return modifyDate(date, value); 32 | }, 33 | day(date, value) { 34 | return modifyDate(date, 0, 0, value); 35 | }, 36 | week(date, value) { 37 | return modifyDate(date, 0, 0, value * 7); 38 | }, 39 | hour(date, value) { 40 | return modifyDate(date, 0, 0, 0, value); 41 | }, 42 | minute(date, value) { 43 | return modifyDate(date, 0, 0, 0, 0, value); 44 | }, 45 | second(date, value) { 46 | return modifyDate(date, 0, 0, 0, 0, 0, value); 47 | }, 48 | milli(date, value) { 49 | return modifyDate(date, 0, 0, 0, 0, 0, 0, value); 50 | } 51 | }; 52 | const helpers = { 53 | assign(...values) { 54 | const originalValue = this.state.value; 55 | return this( 56 | Object.assign( 57 | {}, 58 | originalValue, 59 | ...values.map(value => 60 | typeof value === "function" ? value(originalValue) : value 61 | ) 62 | ) 63 | ); 64 | }, 65 | splice(index, count = 1, ...items) { 66 | return this(this.state.value.slice().splice(index, count, ...items)); 67 | }, 68 | assignProp(prop, ...values) { 69 | const newValue = cloneObject(this.state.value); 70 | const propValue = newValue[prop]; 71 | newValue[prop] = Object.assign( 72 | {}, 73 | propValue, 74 | ...values.map(value => 75 | typeof value === "function" ? value(propValue) : value 76 | ) 77 | ); 78 | return this(newValue); 79 | }, 80 | push(...values) { 81 | return this(this.state.value.concat(values)); 82 | }, 83 | unshift(...values) { 84 | return this(values.concat(this.state.value)); 85 | }, 86 | filter(predicate) { 87 | return this(this.state.value.filter(predicate)); 88 | }, 89 | exclude(...values) { 90 | return this(this.state.value.filter(x => values.includes(x))); 91 | }, 92 | unset(...props) { 93 | const newValue = cloneObject(this.state.value); 94 | for (const prop of props) { 95 | delete newValue[prop]; 96 | } 97 | return this(newValue); 98 | }, 99 | map(mapper) { 100 | return this(this.state.value.map(mapper)); 101 | }, 102 | set(prop, value) { 103 | const newValue = cloneObject(this.state.value); 104 | newValue[prop] = 105 | typeof value === "function" ? value(newValue[prop]) : value; 106 | return this(newValue); 107 | }, 108 | sort(sorter) { 109 | return this(this.state.value.slice().sort(sorter)); 110 | }, 111 | orderBy(selector, desc) { 112 | return this.sort((a, b) => { 113 | const aValue = selector(a), 114 | bValue = selector(b); 115 | return ( 116 | (aValue === bValue ? 0 : aValue > bValue ? 1 : -1) * (desc ? -1 : 1) 117 | ); 118 | }); 119 | }, 120 | toggle(prop) { 121 | if (!arguments.length) { 122 | return this(!this.state.value); 123 | } 124 | const newValue = cloneObject(this.state.value); 125 | newValue[prop] = !newValue[prop]; 126 | return this(newValue); 127 | }, 128 | div(value) { 129 | return this(this.state.value / value); 130 | }, 131 | mul(value) { 132 | return this(this.state.value * value); 133 | }, 134 | add(value, duration = "day") { 135 | const originalValue = this.state.value; 136 | if (originalValue instanceof Date) { 137 | if (duration in dateModifiers) { 138 | return this(dateModifiers[duration](originalValue, value)); 139 | } 140 | throw new Error("Invalid date duration " + duration); 141 | } 142 | return this(this.state.value + value); 143 | }, 144 | replace(searchValue, replaceWith) { 145 | return this(this.state.value.replace(searchValue, replaceWith)); 146 | } 147 | }; 148 | const configs = { 149 | defaultDebounce: 50, 150 | accessorUtils: helpers 151 | }; 152 | let scopes = 0; 153 | let setUniqueId = 1; 154 | 155 | /** 156 | * createState(defaultValue:any) 157 | * createState(dependencies:IState[], functor:Function, options:any) 158 | * @param args 159 | * @return {{async: boolean, computed: boolean, subscribers: Set, value: *, done: boolean}|{async: boolean, computed: boolean, subscribers: Set, value: undefined, done: boolean}} 160 | */ 161 | export function createState(...args) { 162 | const subscribers = {}; 163 | 164 | function unsubscribe(subscriber) { 165 | removeFromSet(subscribers, subscriber); 166 | return this; 167 | } 168 | 169 | function subscribe(subscriber) { 170 | addToSet(subscribers, subscriber); 171 | return this; 172 | } 173 | 174 | let state; 175 | 176 | function getValue(callback, currentValue) { 177 | let newValue; 178 | // is normal object 179 | if (typeof callback !== "function") { 180 | newValue = 181 | // is synthetic event object 182 | callback && callback.target 183 | ? callback.target.type === "checkbox" || 184 | callback.target.type === "radio" 185 | ? callback.target.checked // checkbox 186 | : callback.target.value // other inputs 187 | : callback; 188 | } else { 189 | newValue = callback(currentValue); 190 | } 191 | 192 | if (newValue && newValue.then) { 193 | throw new Error("Do not use this method for async updating"); 194 | } 195 | 196 | return newValue; 197 | } 198 | 199 | function accessor(callback) { 200 | if (!arguments.length) return state.value; 201 | 202 | if (state.computed) { 203 | throw new Error("Cannot update computed state"); 204 | } 205 | 206 | const newValue = getValue(callback, state.value); 207 | 208 | if (newValue !== state.value) { 209 | state.value = newValue; 210 | notify(subscribers); 211 | } 212 | } 213 | 214 | // create simple state 215 | if (args.length < 2) { 216 | const subStates = {}; 217 | return (state = Object.assign(accessor, { 218 | value: args[0], 219 | done: true, 220 | subscribers, 221 | async: false, 222 | computed: false, 223 | merge(value) { 224 | state({ 225 | ...state.value, 226 | value 227 | }); 228 | }, 229 | init: noop, 230 | subscribe, 231 | unsubscribe, 232 | // get sub state by name 233 | get(subStateName, defaultValue) { 234 | state.multiple = true; 235 | let subState = subStates[subStateName]; 236 | 237 | if (!subState) { 238 | subStates[subStateName] = subState = createState(defaultValue); 239 | subState.parent = state; 240 | } 241 | return subState; 242 | }, 243 | // delete sub state by name 244 | delete(subStateName) { 245 | delete subStates[subStateName]; 246 | return this; 247 | }, 248 | invoke(actionBody, ...args) { 249 | return createAction([state], actionBody)(...args); 250 | } 251 | })); 252 | } 253 | 254 | // create computed state 255 | const [ 256 | dependencies, 257 | loader, 258 | { sync, defaultValue = undefined, debounce = configs.defaultDebounce } = {} 259 | ] = args; 260 | 261 | let currentLock; 262 | let keys = []; 263 | let timerId; 264 | let allDone = dependencies.every(x => { 265 | x.init(); 266 | x.subscribe(sync ? callLoaderSync : debouncedCallLoader); 267 | return x.done; 268 | }); 269 | state = Object.assign(accessor, { 270 | dependencies, 271 | value: defaultValue, 272 | done: false, 273 | async: !sync, 274 | computed: true, 275 | init: sync ? callLoaderSync : debouncedCallLoader, 276 | subscribers, 277 | subscribe, 278 | unsubscribe 279 | }); 280 | 281 | const asyncDependencies = dependencies.filter(x => x.async); 282 | 283 | function debouncedCallLoader() { 284 | state.init = noop; 285 | 286 | // make sure all async states should be done 287 | if (asyncDependencies.some(x => !x.done)) return; 288 | // this state is called from another async state so we skip debouncing 289 | if (debounce) { 290 | clearTimeout(timerId); 291 | currentLock = state.lock = {}; 292 | timerId = setTimeout(callLoaderAsync, debounce); 293 | } else { 294 | callLoaderAsync(); 295 | } 296 | } 297 | 298 | function shouldUpdate(callback) { 299 | const newKeys = getStateValues(dependencies, true); 300 | 301 | if (arrayDiff(keys, newKeys)) { 302 | keys = newKeys; 303 | callback(); 304 | } 305 | } 306 | 307 | function callLoaderSync() { 308 | state.init = noop; 309 | shouldUpdate(() => { 310 | state.done = false; 311 | const prevValue = state.value; 312 | state.value = loader(...keys); 313 | state.done = true; 314 | if (state.value !== prevValue) { 315 | notify(subscribers); 316 | } 317 | }); 318 | } 319 | 320 | function callLoaderAsync() { 321 | clearTimeout(timerId); 322 | if (currentLock !== state.lock) return; 323 | shouldUpdate(async () => { 324 | const shouldNotify = state.done !== false || state.error; 325 | 326 | state.done = false; 327 | state.error = undefined; 328 | 329 | const originalValue = state.value; 330 | 331 | if (shouldNotify) { 332 | notify(subscribers); 333 | } 334 | 335 | try { 336 | const value = await loader(...keys); 337 | if (currentLock !== state.lock) return; 338 | if (value !== noChange) { 339 | state.value = value; 340 | } 341 | 342 | state.done = true; 343 | } catch (e) { 344 | if (currentLock !== state.lock) return; 345 | state.error = e; 346 | state.done = true; 347 | } 348 | 349 | // dispatch change 350 | if (state.value !== originalValue) { 351 | notify(subscribers, state); 352 | } 353 | }); 354 | } 355 | 356 | if (allDone) { 357 | if (!state.async) { 358 | callLoaderSync(); 359 | } 360 | } 361 | 362 | return state; 363 | } 364 | 365 | /** 366 | * create an action which depend on specified states 367 | * @param {IState[]} states 368 | * @param {Function} functor 369 | * @return {(Function & {getStates(): *, setStates(*): void})|*} 370 | */ 371 | export function createAction(states, functor) { 372 | let accessors = states.map(createAccessor); 373 | 374 | function performUpdate(subscribers = {}, batchUpdate = false) { 375 | const accessorBag = accessors.slice(); 376 | while (accessorBag.length) { 377 | const accessor = accessorBag.shift(); 378 | if (accessor.subStates) { 379 | accessorBag.push(...Object.values(accessor.subStates)); 380 | } 381 | if (accessor.changed) { 382 | Object.assign(subscribers, accessor.state.subscribers); 383 | let parent = accessor.state.parent; 384 | // notify to all ancestors 385 | while (parent) { 386 | Object.assign(subscribers, parent.subscribers); 387 | parent = parent.parent; 388 | } 389 | accessor.changed = false; 390 | } 391 | } 392 | 393 | if (!batchUpdate) { 394 | notify(subscribers); 395 | } 396 | } 397 | 398 | return Object.assign( 399 | (...args) => { 400 | try { 401 | scopes++; 402 | scopeUpdates.push(performUpdate); 403 | 404 | const result = functor(...accessors, ...args); 405 | 406 | // perform update once async method done 407 | if (result && result.then) { 408 | result.then(performUpdate, performUpdate); 409 | } 410 | 411 | return result; 412 | } finally { 413 | scopes--; 414 | 415 | if (!scopes) { 416 | // collect all subscribers need to be notified 417 | const subscribers = {}; 418 | scopeUpdates 419 | .splice(0, scopeUpdates.length) 420 | .forEach(update => update(subscribers, true)); 421 | 422 | notify(subscribers); 423 | } 424 | } 425 | }, 426 | { 427 | getStates() { 428 | return states; 429 | }, 430 | setStates(newStates) { 431 | accessors = (states = newStates).map(createAccessor); 432 | } 433 | } 434 | ); 435 | } 436 | 437 | export function useStates(...states) { 438 | const [, forceRerender] = useState(); 439 | const unmountRef = useRef(false); 440 | const valuesRef = useRef(); 441 | const statesRef = useRef(); 442 | const hasMapperRef = useRef(); 443 | const statesForCache = states.map(x => (Array.isArray(x) ? x[0] : x)); 444 | if (!valuesRef.current) { 445 | valuesRef.current = getStateValues(states); 446 | } 447 | statesRef.current = states; 448 | hasMapperRef.current = states.some(x => Array.isArray(x)); 449 | 450 | // get unmount status 451 | useEffect( 452 | () => () => { 453 | unmountRef.current = true; 454 | }, 455 | [] 456 | ); 457 | 458 | useEffectWithDynamicArray( 459 | () => { 460 | const checkForUpdates = () => { 461 | // do not rerender if component is unmount 462 | if (unmountRef.current) { 463 | return; 464 | } 465 | const nextValues = getStateValues(statesRef.current); 466 | if (!hasMapperRef.current || arrayDiff(valuesRef.current, nextValues)) { 467 | valuesRef.current = nextValues; 468 | forceRerender({}); 469 | } 470 | }; 471 | 472 | statesForCache.forEach(state => { 473 | state.subscribe(checkForUpdates); 474 | state.init(); 475 | }); 476 | 477 | // some async action may be done at this time 478 | checkForUpdates(); 479 | 480 | return () => { 481 | statesForCache.forEach(state => state.unsubscribe(checkForUpdates)); 482 | }; 483 | }, 484 | // just run this effect once state list changed, has no effect if mapper changed 485 | statesForCache 486 | ); 487 | 488 | return valuesRef.current; 489 | } 490 | 491 | /** 492 | * 493 | * @param stateMap 494 | * @param fallbackOrOptions 495 | * @return {function(*=): Function} 496 | */ 497 | export function withAsyncStates(stateMap, fallbackOrOptions) { 498 | if ( 499 | typeof fallbackOrOptions === "function" || 500 | typeof fallbackOrOptions === "boolean" || 501 | // support styled component 502 | (fallbackOrOptions && fallbackOrOptions.styledComponentId) 503 | ) { 504 | fallbackOrOptions = { fallback: fallbackOrOptions }; 505 | } 506 | 507 | const { fallback } = fallbackOrOptions; 508 | 509 | const entries = Object.entries(stateMap || {}); 510 | const states = entries.map(x => x[1]); 511 | 512 | if (states.some(state => !state.async)) { 513 | throw new Error("Expect async state but got sync state"); 514 | } 515 | 516 | return comp => { 517 | const memoizedComp = memo(comp); 518 | return props => { 519 | const results = useStates(...states); 520 | const newProps = {}; 521 | 522 | let allDone = true; 523 | 524 | results.forEach((result, index) => { 525 | const prop = entries[index][0]; 526 | newProps[prop] = states[index]; 527 | if (!result.done || result.error) { 528 | allDone = false; 529 | } else { 530 | newProps[prop + "Done"] = true; 531 | } 532 | }); 533 | 534 | if (!allDone && fallback !== false) { 535 | return fallback ? createElement(fallback, props) : null; 536 | } 537 | 538 | Object.assign(newProps, props); 539 | 540 | return createElement(memoizedComp, newProps); 541 | }; 542 | }; 543 | } 544 | 545 | /** 546 | * use this method for testing only 547 | * sample: 548 | * mock([ 549 | * [Action1, [State1, State2]], 550 | * [Action2, [false, State2]] // we leave first state, no overwrite 551 | * ],async () => { 552 | * do something, functor can be async function 553 | * ) 554 | * @param actionMockings 555 | * @param functor 556 | */ 557 | export function mock(actionMockings, functor) { 558 | const originalStates = new WeakMap(); 559 | let done = false; 560 | actionMockings.forEach(mocking => { 561 | const states = mocking[0].getStates(); 562 | originalStates.set( 563 | mocking[0], 564 | // using original state if input state is falsy 565 | mocking[1].map((state, index) => state || states[index]) 566 | ); 567 | }); 568 | 569 | function unmock() { 570 | actionMockings.forEach(mocking => 571 | mocking[0].setStates(originalStates.get(mocking[0])) 572 | ); 573 | } 574 | try { 575 | const result = functor(); 576 | if (result && result.then) { 577 | result.then(unmock, unmock); 578 | } else { 579 | done = true; 580 | } 581 | return result; 582 | } finally { 583 | if (done) { 584 | unmock(); 585 | } 586 | } 587 | } 588 | 589 | /** 590 | * update multiple states from specific data 591 | * @param stateMap 592 | * @param data 593 | */ 594 | export function updateStates(stateMap, data = {}) { 595 | Object.keys(stateMap).forEach(key => { 596 | // do not overwrite state value if the key is not present in data 597 | if (!(key in data)) return; 598 | const state = stateMap[key]; 599 | if (state.computed) { 600 | throw new Error("Cannot update computed state"); 601 | } 602 | state(data[key]); 603 | }); 604 | } 605 | 606 | /** 607 | * export multiple states to json object 608 | * @param stateMap 609 | */ 610 | export function exportStates(stateMap) { 611 | const values = {}; 612 | 613 | Object.keys(stateMap).forEach(key => { 614 | values[key] = stateMap[key](); 615 | }); 616 | 617 | return values; 618 | } 619 | 620 | /** 621 | * perfom loading/saving multiple states automatically 622 | * @param states 623 | * @param data 624 | * @param onChange 625 | * @param debounce 626 | */ 627 | export function persistStates( 628 | states, 629 | data, 630 | onChange, 631 | debounce = configs.defaultDebounce 632 | ) { 633 | updateStates(states, data); 634 | let timerId; 635 | function debouncedHandleChange() { 636 | if (debounce) { 637 | clearTimeout(timerId); 638 | timerId = setTimeout(handleChange, debounce); 639 | } else { 640 | handleChange(); 641 | } 642 | } 643 | 644 | function handleChange() { 645 | clearTimeout(timerId); 646 | const values = exportStates(states); 647 | onChange && onChange(values); 648 | } 649 | 650 | Object.values(states).forEach(state => 651 | state.subscribe(debouncedHandleChange) 652 | ); 653 | } 654 | 655 | export function compose(...functions) { 656 | if (functions.length === 0) { 657 | return arg => arg; 658 | } 659 | 660 | if (functions.length === 1) { 661 | return functions[0]; 662 | } 663 | 664 | return functions.reduce((a, b) => (...args) => a(b(...args))); 665 | } 666 | 667 | export function hoc(...callbacks) { 668 | return callbacks.reduce( 669 | (nextHoc, callback) => Component => { 670 | const MemoComponent = memo(Component); 671 | 672 | return props => { 673 | // callback requires props and Comp, it must return React element 674 | if (callback.length > 1) { 675 | return callback(props, MemoComponent); 676 | } 677 | let newProps = callback(props); 678 | if (newProps === false) return null; 679 | if (!newProps) { 680 | newProps = props; 681 | } 682 | 683 | return createElement(MemoComponent, newProps); 684 | }; 685 | }, 686 | Component => Component 687 | ); 688 | } 689 | 690 | export function memoize(f) { 691 | let lastResult; 692 | let lastArgs; 693 | 694 | return function(...args) { 695 | // call f on first time or args changed 696 | if (!lastArgs || arrayDiff(lastArgs, args)) { 697 | lastArgs = args; 698 | lastResult = f(...lastArgs); 699 | } 700 | return lastResult; 701 | }; 702 | } 703 | 704 | export function AsyncRender({ 705 | render, 706 | error, 707 | children, 708 | state, 709 | states, 710 | prop = "data", 711 | ...props 712 | }) { 713 | const results = useStates(...(states || [state])); 714 | const allDone = results.every(result => result.done); 715 | 716 | if (!allDone) { 717 | return children; 718 | } 719 | 720 | const errorObject = state 721 | ? state.error 722 | : states.filter(x => x.error).map(x => x.error)[0]; 723 | 724 | if (errorObject) { 725 | if (error) { 726 | return createElement(error, errorObject); 727 | } 728 | return children; 729 | } 730 | 731 | const data = state ? results[0]() : getStateValues(results, true); 732 | 733 | if (render) { 734 | return createElement( 735 | render, 736 | prop 737 | ? { 738 | [prop]: data, 739 | ...props 740 | } 741 | : { 742 | ...data, 743 | ...props 744 | } 745 | ); 746 | } 747 | 748 | return data; 749 | } 750 | 751 | export function configure(options = {}) { 752 | if (typeof options === "function") { 753 | options = options(configs); 754 | } 755 | Object.assign(configs, options); 756 | } 757 | 758 | function arrayDiff(a, b) { 759 | return a.length !== b.length || a.some((i, index) => i !== b[index]); 760 | } 761 | 762 | function addToSet(set, functor) { 763 | if (!functor.__id__) { 764 | functor.__id__ = setUniqueId++; 765 | } 766 | 767 | if (functor.__id__ in set) { 768 | return; 769 | } 770 | 771 | set[functor.__id__] = functor; 772 | } 773 | 774 | function removeFromSet(set, functor) { 775 | if (functor.__id__) { 776 | delete set[functor.__id__]; 777 | } 778 | } 779 | 780 | function notify(subscribers) { 781 | for (const subscriber of Object.values(subscribers)) { 782 | subscriber(); 783 | } 784 | } 785 | 786 | function createAccessor(state) { 787 | const accessor = function(value, ...args) { 788 | if (arguments.length) { 789 | if (state.computed) { 790 | throw new Error("Cannot update computed state"); 791 | } 792 | 793 | if (configs.transform) { 794 | value = configs.transform(state.value, value, ...args); 795 | } else if (typeof value === "function") { 796 | value = value(state.value); 797 | } 798 | 799 | if (state.value !== value) { 800 | state.value = value; 801 | accessor.changed = true; 802 | } 803 | 804 | return accessor; 805 | } 806 | 807 | return state.value; 808 | }; 809 | return Object.assign(accessor, { 810 | ...helpers, 811 | state, 812 | changed: false, 813 | get(subStateName) { 814 | if (!this.subStates) { 815 | this.subStates = {}; 816 | } 817 | return ( 818 | this.subStates[subStateName] || 819 | (this.subStates[subStateName] = createAccessor( 820 | this.state.get(subStateName) 821 | )) 822 | ); 823 | } 824 | }); 825 | } 826 | 827 | function getStateValues(states, valueOnly) { 828 | return states.map(x => { 829 | const [state, mapper, ...mapperArgs] = Array.isArray(x) ? x : [x]; 830 | state.init(); 831 | const result = valueOnly ? state() : state.async ? state : state(); 832 | if (mapper && !mapper.__memoizedMapper) { 833 | mapper.__memoizedMapper = memoize(mapper); 834 | } 835 | return mapper 836 | ? mapperArgs.length 837 | ? mapper.__memoizedMapper(result, ...mapperArgs) 838 | : mapper(result) 839 | : result; 840 | }); 841 | } 842 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./*" 4 | ], 5 | "compilerOptions": { 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "es2017" 10 | ], 11 | "downlevelIteration": true, 12 | "jsx": "react" 13 | } 14 | } 15 | --------------------------------------------------------------------------------