├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.json ├── docs └── ssr.md ├── jest.config.json ├── jest.setup-once.ts ├── lerna.json ├── og.png ├── package.json ├── rollup.config.js ├── src ├── index.ts └── lib │ ├── react-shared-internals.ts │ └── use-force-update.ts ├── tests ├── effect.test.tsx ├── example.test.tsx ├── functions.test.ts ├── subscription.test.tsx ├── use-between.test.tsx └── use-initial.test.tsx └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | indent_style = space 14 | indent_size = 2 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # betula 5 | open_collective: use-between # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # betula 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | env: 4 | CI: true 5 | 6 | jobs: 7 | run: 8 | name: Node ${{ matrix.node }} on ${{ matrix.os }} 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node: [12] 15 | os: [ubuntu-latest, windows-latest] 16 | 17 | steps: 18 | - name: Clone repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Set Node.js version 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node }} 25 | 26 | - run: node --version 27 | - run: npm --version 28 | 29 | - name: Install, build, and test 30 | run: npm run bootstrap 31 | 32 | - name: Coveralls 33 | uses: coverallsapp/github-action@master 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs and editors 2 | .idea 3 | .project 4 | .classpath 5 | .c9/ 6 | *.launch 7 | .settings/ 8 | *.sublime-workspace 9 | 10 | # IDE - VSCode 11 | .vscode/* 12 | !.vscode/settings.json 13 | !.vscode/tasks.json 14 | !.vscode/launch.json 15 | !.vscode/extensions.json 16 | 17 | # OS files 18 | .DS_Store 19 | Thumbs.db 20 | 21 | # dependencies 22 | node_modules 23 | package-lock.json 24 | 25 | # logs 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | lerna-debug.log* 30 | 31 | # testing 32 | /coverage 33 | 34 | # production 35 | /release 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Slava Bereza 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 | # use-between 2 | 3 | [![npm version](https://img.shields.io/npm/v/use-between?style=flat-square)](https://www.npmjs.com/package/use-between) [![build status](https://img.shields.io/github/actions/workflow/status/betula/use-between/tests.yml?branch=master&style=flat-square)](https://github.com/betula/use-between/actions?workflow=Tests) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/use-between?style=flat-square)](https://bundlephobia.com/result?p=use-between) [![code coverage](https://img.shields.io/coveralls/github/betula/use-between?style=flat-square)](https://coveralls.io/github/betula/use-between) [![typescript supported](https://img.shields.io/npm/types/typescript?style=flat-square)](https://github.com/betula/use-between) [![100k+ downloaded](https://img.shields.io/npm/dt/use-between?style=flat-square)](https://www.npmjs.com/package/use-between) 4 | 5 | When you want to separate your React hooks between several components it's can be very difficult, because all context data stored in React component function area. 6 | If you want to share some of state parts or control functions to another component your need pass It thought React component props. But If you want to share It with sibling one level components or a set of scattered components, you will be frustrated. 7 | 8 | `useBetween` hook is the solution to your problem :kissing_closed_eyes: 9 | 10 | ```javascript 11 | import React, { useState, useCallback } from 'react'; 12 | import { useBetween } from 'use-between'; 13 | 14 | const useCounter = () => { 15 | const [count, setCount] = useState(0); 16 | const inc = useCallback(() => setCount(c => c + 1), []); 17 | const dec = useCallback(() => setCount(c => c - 1), []); 18 | return { 19 | count, 20 | inc, 21 | dec 22 | }; 23 | }; 24 | 25 | const useSharedCounter = () => useBetween(useCounter); 26 | 27 | const Count = () => { 28 | const { count } = useSharedCounter(); 29 | return

{count}

; 30 | }; 31 | 32 | const Buttons = () => { 33 | const { inc, dec } = useSharedCounter(); 34 | return ( 35 | <> 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | const App = () => ( 43 | <> 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | 51 | export default App; 52 | ``` 53 | [![Edit Counter with useBetween](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/counter-with-usebetween-zh4tp?file=/src/App.js) 54 | 55 | `useBetween` is a way to call any hook. But so that the state will not be stored in the React component. For the same hook, the result of the call will be the same. So we can call one hook in different components and work together on one state. When updating the shared state, each component using it will be updated too. 56 | 57 | If you like this idea and would like to use it, please put star in github. It will be your first contribution! 58 | 59 | ### Developers :sparkling_heart: use-between 60 | 61 | > Hey [@betula](https://github.com/betula), just wanted to say thank you for this awesome library! ✋ 62 | > Switching from React Context + useReducer to this library reduced soooo much boilerplate code. 63 | > It's much more nice, clean and simple now, plus the bonus of using "useEffect" incapsulated within the state hook is just awesome. 64 | > 65 | > I don't get why this library doesn't have more stars and more popularity. People using useContext+useReducer are really missing out 😃 66 | > 67 | > [**Jesper**, _This library should have way more stars! 🥇_](https://github.com/betula/use-between/issues/14) 68 | 69 | 70 | > [@betula](https://github.com/betula) as I mentioned before this lib is awesome and it allowed me to simplify an app that was using Redux. I was able to replace everything we were doing with Redux with just use-between and its tiny 2K footprint! 71 | > 72 | > Plus personally I think the code is cleaner because with use-between it just looks like normal hooks and not anything special like Redux code. I personally find it easier to read and understand than Redux! 73 | > 74 | > [**Melloware**, _Release discussion_](https://github.com/betula/use-between/discussions/20#discussioncomment-1715792) 75 | 76 | > I was about to install Redux until I found this library and it is a live saver. Really awesome job [@betula](https://github.com/betula). I don't know if I ever need to use Redux again haha 77 | > 78 | > [**Ronald Castillo**](https://github.com/betula/use-between/issues/14#issuecomment-1050601343) 79 | 80 | ### Supported hooks 81 | 82 | ```diff 83 | + useState 84 | + useEffect 85 | + useReducer 86 | + useCallback 87 | + useMemo 88 | + useRef 89 | + useImperativeHandle 90 | ``` 91 | 92 | If you found some bug or want to propose improvement please [make an Issue](https://github.com/betula/use-between/issues/new) or join to [release discussion on Github](https://github.com/betula/use-between/discussions/35). I would be happy for your help to make It better! :wink: 93 | 94 | + [How to use SSR](./docs/ssr.md) 95 | + [Try Todos demo on CodeSandbox](https://codesandbox.io/s/todos-use-bettwen-8d2th?file=/src/components/todo-list.jsx) 96 | + [The article “Reuse React hooks in state sharing” on dev.to](https://dev.to/betula/reuse-react-hooks-in-state-sharing-1ell) 97 | 98 | ### Install 99 | 100 | ```bash 101 | npm install use-between 102 | # or 103 | yarn add use-between 104 | ``` 105 | 106 | Enjoy and happy coding! 107 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "node": "current" 6 | } 7 | }], 8 | "@babel/preset-react", 9 | "@babel/preset-typescript" 10 | ], 11 | "plugins": [] 12 | } 13 | -------------------------------------------------------------------------------- /docs/ssr.md: -------------------------------------------------------------------------------- 1 | # How to use SSR 2 | 3 | [Example of using SSR with Next.js on Github](https://github.com/betula/use-between-ssr) 4 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": ".", 3 | "setupFiles": ["/jest.setup-once.ts"], 4 | "testMatch": [ 5 | "/tests/**/*.ts*" 6 | ], 7 | "moduleNameMapper": { 8 | "~/(.*)$": "/src/$1" 9 | }, 10 | "verbose": true 11 | } 12 | -------------------------------------------------------------------------------- /jest.setup-once.ts: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({adapter: new Adapter()}); 5 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.3.5", 3 | "packages": [ 4 | "." 5 | ], 6 | "npmClient": "npm", 7 | "loglevel": "verbose" 8 | } 9 | -------------------------------------------------------------------------------- /og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betula/use-between/ddd50040c1e97a45bd2f809f30e209b7401ab2dc/og.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-between", 3 | "version": "1.3.5", 4 | "description": "How to share React hooks state between components", 5 | "repository": { 6 | "url": "https://github.com/betula/use-between" 7 | }, 8 | "bugs": { 9 | "url": "https://github.com/betula/use-between/issues" 10 | }, 11 | "license": "MIT", 12 | "scripts": { 13 | "build": "rollup -c", 14 | "dev": "rollup -cw", 15 | "test": "jest --coverage", 16 | "clean": "rimraf ./release && rimraf ./coverage", 17 | "bootstrap": "npm install && npm run build && npm run test", 18 | "reset": "npm run clean && lerna clean --yes && npm run bootstrap", 19 | "publish": "npm run clean && npm run build && npm run test && lerna publish" 20 | }, 21 | "author": "Slava Bereza (http://betula.co)", 22 | "keywords": [ 23 | "model", 24 | "state", 25 | "reactive", 26 | "shared state", 27 | "use between", 28 | "react hooks", 29 | "react", 30 | "javascript", 31 | "typescript" 32 | ], 33 | "source": "./src/index.ts", 34 | "main": "./release/index.js", 35 | "module": "./release/index.esm.js", 36 | "types": "./release/index.d.ts", 37 | "sideEffects": false, 38 | "files": [ 39 | "/release" 40 | ], 41 | "peerDependencies": { 42 | "react": ">=16.8.0" 43 | }, 44 | "devDependencies": { 45 | "@babel/preset-env": "^7.9.6", 46 | "@babel/preset-react": "^7.9.4", 47 | "@babel/preset-typescript": "^7.9.0", 48 | "@testing-library/react": "^12.1.2", 49 | "@types/enzyme": "^3.10.5", 50 | "@types/enzyme-adapter-react-16": "^1.0.6", 51 | "@types/jest": "^25.2.3", 52 | "@types/node": "^14.0.5", 53 | "@types/react": "^16.9.35", 54 | "@types/react-dom": "^16.9.8", 55 | "enzyme": "^3.11.0", 56 | "enzyme-adapter-react-16": "^1.15.2", 57 | "jest": "^26.0.1", 58 | "lerna": "^3.22.0", 59 | "react": "^16.13.1", 60 | "react-dom": "^16.13.1", 61 | "rimraf": "^3.0.2", 62 | "rollup": "^2.10.9", 63 | "rollup-plugin-typescript2": "^0.27.1", 64 | "typescript": "^3.9.3" 65 | }, 66 | "publishConfig": { 67 | "access": "public" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import pkg, { peerDependencies } from './package.json'; 4 | 5 | export default { 6 | input: pkg.source, 7 | output: [{ 8 | file: pkg.main, 9 | format: 'cjs' 10 | },{ 11 | file: pkg.module, 12 | format: 'es' 13 | }], 14 | external: Object.keys(peerDependencies), 15 | plugins: [ typescript() ] 16 | } 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { ReactCurrentDispatcher } from './lib/react-shared-internals' 3 | import { useForceUpdate } from './lib/use-force-update' 4 | 5 | const notImplemented = (name: string) => () => { 6 | const msg = `Hook "${name}" no possible to using inside useBetween scope.` 7 | console.error(msg) 8 | throw new Error(msg) 9 | } 10 | 11 | const equals = (a: any, b: any) => Object.is(a, b) 12 | const shouldUpdate = (a: any[], b: any[]) => ( 13 | (!a || !b) || 14 | (a.length !== b.length) || 15 | a.some((dep: any, index: any) => !equals(dep, b[index])) 16 | ) 17 | 18 | const detectServer = () => typeof window === 'undefined' 19 | 20 | const instances = new Map() 21 | 22 | let boxes = [] as any[] 23 | let pointer = 0 24 | let useEffectQueue = [] as any[] 25 | let useLayoutEffectQueue = [] as any[] 26 | let nextTick = () => {} 27 | 28 | let isServer = detectServer() 29 | let initialData = undefined as any 30 | 31 | const nextBox = () => { 32 | const index = pointer ++ 33 | return (boxes[index] = boxes[index] || {}) 34 | } 35 | 36 | const ownDisptacher = { 37 | useState(initialState?: any) { 38 | const box = nextBox() 39 | const tick = nextTick 40 | 41 | if (!box.initialized) { 42 | box.state = typeof initialState === "function" ? initialState() : initialState 43 | box.set = (fn: any) => { 44 | if (typeof fn === 'function') { 45 | return box.set(fn(box.state)) 46 | } 47 | if (!equals(fn, box.state)) { 48 | box.state = fn 49 | tick() 50 | } 51 | } 52 | box.initialized = true 53 | } 54 | 55 | return [ box.state, box.set ] 56 | }, 57 | 58 | useReducer(reducer: any, initialState?: any, init?: any) { 59 | const box = nextBox() 60 | const tick = nextTick 61 | 62 | if (!box.initialized) { 63 | box.state = init ? init(initialState) : initialState 64 | box.dispatch = (action: any) => { 65 | const state = reducer(box.state, action) 66 | if (!equals(state, box.state)) { 67 | box.state = state 68 | tick() 69 | } 70 | } 71 | box.initialized = true 72 | } 73 | 74 | return [ box.state, box.dispatch ] 75 | }, 76 | 77 | useEffect(fn: any, deps: any[]) { 78 | if (isServer) return 79 | const box = nextBox() 80 | 81 | if (!box.initialized) { 82 | box.deps = deps 83 | box.initialized = true 84 | useEffectQueue.push([box, deps, fn]) 85 | } 86 | else if (shouldUpdate(box.deps, deps)) { 87 | box.deps = deps 88 | useEffectQueue.push([box, deps, fn]) 89 | } 90 | }, 91 | 92 | useLayoutEffect(fn: any, deps: any[]) { 93 | if (isServer) return 94 | const box = nextBox() 95 | 96 | if (!box.initialized) { 97 | box.deps = deps 98 | box.initialized = true 99 | useLayoutEffectQueue.push([box, deps, fn]) 100 | } 101 | else if (shouldUpdate(box.deps, deps)) { 102 | box.deps = deps 103 | useLayoutEffectQueue.push([box, deps, fn]) 104 | } 105 | }, 106 | 107 | useCallback(fn: any, deps: any[]) { 108 | const box = nextBox() 109 | 110 | if (!box.initialized) { 111 | box.fn = fn 112 | box.deps = deps 113 | box.initialized = true 114 | } 115 | else if (shouldUpdate(box.deps, deps)) { 116 | box.deps = deps 117 | box.fn = fn 118 | } 119 | 120 | return box.fn 121 | }, 122 | 123 | useMemo(fn: any, deps: any[]) { 124 | const box = nextBox() 125 | 126 | if (!box.initialized) { 127 | box.deps = deps 128 | box.state = fn() 129 | box.initialized = true 130 | } 131 | else if (shouldUpdate(box.deps, deps)) { 132 | box.deps = deps 133 | box.state = fn() 134 | } 135 | 136 | return box.state 137 | }, 138 | 139 | useRef(initialValue: any) { 140 | const box = nextBox() 141 | 142 | if (!box.initialized) { 143 | box.state = { current: initialValue } 144 | box.initialized = true 145 | } 146 | 147 | return box.state 148 | }, 149 | 150 | useImperativeHandle(ref: any, fn: any, deps: any[]) { 151 | if (isServer) return 152 | const box = nextBox() 153 | 154 | if (!box.initialized) { 155 | box.deps = deps 156 | box.initialized = true 157 | useLayoutEffectQueue.push([box, deps, () => { 158 | typeof ref === 'function' ? ref(fn()) : ref.current = fn() 159 | }]) 160 | } 161 | else if (shouldUpdate(box.deps, deps)) { 162 | box.deps = deps 163 | useLayoutEffectQueue.push([box, deps, () => { 164 | typeof ref === 'function' ? ref(fn()) : ref.current = fn() 165 | }]) 166 | } 167 | } 168 | } 169 | ;[ 170 | 'readContext', 171 | 'useContext', 172 | 'useDebugValue', 173 | 'useResponder', 174 | 'useDeferredValue', 175 | 'useTransition' 176 | ].forEach(key => (ownDisptacher as any)[key] = notImplemented(key)) 177 | 178 | const factory = (hook: any, options?: any) => { 179 | const scopedBoxes = [] as any[] 180 | let syncs = [] as any[] 181 | let state = undefined as any 182 | let unsubs = [] as any[] 183 | let mocked = false 184 | 185 | if (options && options.mock) { 186 | state = options.mock 187 | mocked = true 188 | } 189 | 190 | const sync = () => { 191 | syncs.slice().forEach(fn => fn()) 192 | } 193 | 194 | const tick = () => { 195 | if (mocked) return 196 | 197 | const originDispatcher = ReactCurrentDispatcher.current 198 | const originState = [ 199 | pointer, 200 | useEffectQueue, 201 | useLayoutEffectQueue, 202 | boxes, 203 | nextTick 204 | ] as any 205 | 206 | let tickAgain = false 207 | let tickBody = true 208 | 209 | pointer = 0 210 | useEffectQueue = [] 211 | useLayoutEffectQueue = [] 212 | boxes = scopedBoxes 213 | 214 | nextTick = () => { 215 | if (tickBody) { 216 | tickAgain = true 217 | } else { 218 | tick() 219 | } 220 | } 221 | 222 | ReactCurrentDispatcher.current = ownDisptacher as any 223 | state = hook(initialData) 224 | 225 | ;[ useLayoutEffectQueue, useEffectQueue ].forEach(queue => ( 226 | queue.forEach(([box, deps, fn]) => { 227 | box.deps = deps 228 | if (box.unsub) { 229 | const unsub = box.unsub 230 | unsubs = unsubs.filter(fn => fn !== unsub) 231 | unsub() 232 | } 233 | const unsub = fn() 234 | if (typeof unsub === "function") { 235 | unsubs.push(unsub) 236 | box.unsub = unsub 237 | } else { 238 | box.unsub = null 239 | } 240 | }) 241 | )) 242 | 243 | ;[ 244 | pointer, 245 | useEffectQueue, 246 | useLayoutEffectQueue, 247 | boxes, 248 | nextTick 249 | ] = originState 250 | ReactCurrentDispatcher.current = originDispatcher 251 | 252 | tickBody = false 253 | if (!tickAgain) { 254 | sync() 255 | return 256 | } 257 | tick() 258 | } 259 | 260 | const sub = (fn: any) => { 261 | if (syncs.indexOf(fn) === -1) { 262 | syncs.push(fn) 263 | } 264 | } 265 | const unsub = (fn: any) => { 266 | syncs = syncs.filter(f => f !== fn) 267 | } 268 | 269 | const mock = (obj: any) => { 270 | mocked = true 271 | state = obj 272 | sync() 273 | } 274 | const unmock = () => { 275 | mocked = false 276 | tick() 277 | } 278 | 279 | return { 280 | init: () => tick(), 281 | get: () => state, 282 | sub, 283 | unsub, 284 | unsubs: () => unsubs, 285 | mock, 286 | unmock 287 | } 288 | } 289 | 290 | const getInstance = (hook: any): any => { 291 | let inst = instances.get(hook) 292 | if (!inst) { 293 | inst = factory(hook) 294 | instances.set(hook, inst) 295 | inst.init() 296 | } 297 | return inst 298 | } 299 | 300 | type Hook = (initialData?: any) => T 301 | 302 | export const useBetween = (hook: Hook): T => { 303 | const forceUpdate = useForceUpdate() 304 | let inst = getInstance(hook) 305 | inst.sub(forceUpdate) 306 | useEffect( 307 | () => (inst.sub(forceUpdate), () => inst.unsub(forceUpdate)), 308 | [inst, forceUpdate] 309 | ) 310 | return inst.get() 311 | } 312 | 313 | export const useInitial = (data?: T, server?: boolean) => { 314 | const ref = useRef() 315 | if (!ref.current) { 316 | isServer = typeof server === 'undefined' ? detectServer() : server 317 | isServer && clear() 318 | initialData = data 319 | ref.current = 1 320 | } 321 | } 322 | 323 | export const mock = (hook: Hook, state: any): () => void => { 324 | let inst = instances.get(hook) 325 | if (inst) inst.mock(state) 326 | else { 327 | inst = factory(hook, { mock: state }) 328 | instances.set(hook, inst) 329 | } 330 | return inst.unmock 331 | } 332 | 333 | export const get = (hook: Hook): T => getInstance(hook).get() 334 | 335 | export const free = function(...hooks: Hook[]): void { 336 | if (!hooks.length) { 337 | hooks = [] 338 | instances.forEach((_instance, hook) => hooks.push(hook)) 339 | } 340 | 341 | let inst 342 | hooks.forEach((hook) => ( 343 | (inst = instances.get(hook)) && 344 | inst.unsubs().slice().forEach((fn: any) => fn()) 345 | )) 346 | hooks.forEach((hook) => instances.delete(hook)) 347 | } 348 | 349 | export const clear = () => instances.clear() 350 | 351 | export const on = (hook: Hook, fn: (state: T) => void): () => void => { 352 | const inst = getInstance(hook) 353 | const listener = () => fn(inst.get()) 354 | inst.sub(listener) 355 | return () => inst.unsub(listener) 356 | } 357 | 358 | -------------------------------------------------------------------------------- /src/lib/react-shared-internals.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | type AnyHook = (...args: any[]) => any; 4 | type ReactSharedInternalsType = { 5 | ReactCurrentDispatcher: { 6 | current?: { 7 | [name: string]: AnyHook 8 | }; 9 | }; 10 | } 11 | 12 | export const ReactSharedInternals = 13 | (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as ReactSharedInternalsType 14 | 15 | export const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher 16 | -------------------------------------------------------------------------------- /src/lib/use-force-update.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from 'react' 2 | 3 | export const useForceUpdate = () => (useReducer as any)(() => ({}))[1] as () => void 4 | -------------------------------------------------------------------------------- /tests/effect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { render } from '@testing-library/react' 3 | import { mount } from 'enzyme' 4 | import { clear, get, useBetween } from '../src' 5 | 6 | 7 | afterEach(clear) 8 | 9 | 10 | it('Effect should works asynchronous for hooks body', async () => { 11 | let inc = 0 12 | const effect_spy = jest.fn() 13 | const hook_finish_spy = jest.fn() 14 | 15 | const hook = () => { 16 | const [ curr, update ] = useState(0) 17 | useEffect(() => { 18 | effect_spy(++inc, curr) 19 | }, [curr]) 20 | hook_finish_spy(++inc) 21 | return { update } 22 | } 23 | 24 | expect(effect_spy).toBeCalledTimes(0) 25 | expect(hook_finish_spy).toBeCalledTimes(0) 26 | get(hook) 27 | expect(effect_spy).toBeCalledTimes(1) 28 | expect(effect_spy).toBeCalledWith(2, 0) 29 | expect(hook_finish_spy).toBeCalledTimes(1) 30 | expect(hook_finish_spy).toBeCalledWith(1) 31 | 32 | get(hook).update(7) 33 | expect(effect_spy).toBeCalledTimes(2) 34 | expect(effect_spy).toHaveBeenLastCalledWith(4, 7) 35 | expect(hook_finish_spy).toBeCalledTimes(2) 36 | expect(hook_finish_spy).toHaveBeenLastCalledWith(3) 37 | }) 38 | 39 | it('Effect should update state during hooks creation', async () => { 40 | const hook = () => { 41 | const [loading, setLoading] = useState(false) 42 | 43 | useEffect(() => { 44 | setLoading(true) 45 | }, []) 46 | 47 | return loading 48 | } 49 | const A = () => { 50 | const loading = useBetween(hook) 51 | return {loading ? 'loading' : ''} 52 | } 53 | 54 | const el = render() 55 | expect((await el.findByTestId('loading')).textContent).toBe('loading') 56 | 57 | clear() 58 | const ol = mount() 59 | expect(ol.find('i').text()).toBe('loading') 60 | }) 61 | 62 | -------------------------------------------------------------------------------- /tests/example.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react' 2 | import { clear, get, on, useBetween, mock } from '../src' 3 | import { act, render } from '@testing-library/react' 4 | import { mount } from 'enzyme' 5 | 6 | // ./shared-counter.js 7 | const useCounter = () => { 8 | const [count, setCount] = useState(0) 9 | const inc = useCallback(() => setCount((c) => c + 1), []) 10 | const dec = useCallback(() => setCount((c) => c - 1), []) 11 | return { 12 | count, 13 | inc, 14 | dec 15 | } 16 | } 17 | 18 | // ./shared-counter.test.js 19 | 20 | // Clean up after each test if necessary 21 | afterEach(clear) // or afterEach(() => clear()) 22 | 23 | // Test example 24 | it('It works', async () => { 25 | expect(get(useCounter).count).toBe(0) 26 | 27 | get(useCounter).inc() 28 | 29 | // Check result 30 | expect(get(useCounter).count).toBe(1) 31 | 32 | get(useCounter).inc() 33 | expect(get(useCounter).count).toBe(2) 34 | }) 35 | 36 | it('It works with spy', () => { 37 | const spy = jest.fn() 38 | 39 | // Subscribe to a state change 40 | on(useCounter, (state) => spy(state.count)) 41 | 42 | get(useCounter).inc() 43 | expect(spy).toBeCalledWith(1) 44 | 45 | get(useCounter).dec() 46 | expect(spy).toHaveBeenLastCalledWith(0) 47 | }) 48 | 49 | it('It works with enzyme render component', async () => { 50 | const Counter = () => { 51 | const { count } = useBetween(useCounter) 52 | return {count} 53 | } 54 | 55 | const el = mount() 56 | expect(el.find('i').text()).toBe('0') 57 | 58 | // You should use "act" from @testing-library/react 59 | act(() => { 60 | get(useCounter).inc() 61 | }) 62 | expect(el.find('i').text()).toBe('1') 63 | }) 64 | 65 | it('It works with testing-library render component', async () => { 66 | const Counter = () => { 67 | const { count } = useBetween(useCounter) 68 | return {count} 69 | } 70 | 71 | const el = render() 72 | expect((await el.findByTestId('count')).textContent).toBe('0') 73 | 74 | // You should use "act" from @testing-library/react 75 | act(() => { 76 | get(useCounter).dec() 77 | }) 78 | expect((await el.findByTestId('count')).textContent).toBe('-1') 79 | }) 80 | 81 | it('It works with testing-library render component with mock', async () => { 82 | const Counter = () => { 83 | const { count } = useBetween(useCounter) 84 | return {count} 85 | } 86 | mock(useCounter, { count: 10 }) 87 | 88 | const el = render() 89 | expect((await el.findByTestId('count')).textContent).toBe('10') 90 | 91 | // You should use "act" from @testing-library/react 92 | act(() => { 93 | mock(useCounter, { count: 15 }) 94 | }) 95 | expect((await el.findByTestId('count')).textContent).toBe('15') 96 | }) 97 | -------------------------------------------------------------------------------- /tests/functions.test.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { get, free, clear, on, mock, useBetween } from '../src' 3 | 4 | afterEach(clear) 5 | 6 | test('Should work get function', async () => { 7 | const counter_spy = jest.fn() 8 | const useCounter = () => { 9 | counter_spy() 10 | const [count, setCount] = useState(10) 11 | return { count, setCount } 12 | } 13 | 14 | expect(counter_spy).toBeCalledTimes(0) 15 | expect(get(useCounter).count).toBe(10) 16 | expect(counter_spy).toBeCalledTimes(1) 17 | expect(get(useCounter).count).toBe(10) 18 | expect(counter_spy).toBeCalledTimes(1) 19 | 20 | get(useCounter).setCount(v => v + 5) 21 | 22 | expect(get(useCounter).count).toBe(15) 23 | expect(counter_spy).toBeCalledTimes(2) 24 | 25 | clear() 26 | expect(get(useCounter).count).toBe(10) 27 | expect(counter_spy).toBeCalledTimes(3) 28 | }) 29 | 30 | test('Should work free function', () => { 31 | const effect_spy = jest.fn() 32 | const un_effect_spy = jest.fn() 33 | const useCounter = () => { 34 | const [count, setCount] = useState(10) 35 | useEffect(() => (effect_spy(count), () => un_effect_spy(count)), [count]) 36 | return { count, setCount } 37 | } 38 | 39 | const { count, setCount} = get(useCounter) 40 | expect(count).toBe(10) 41 | expect(effect_spy).toBeCalledWith(10) 42 | 43 | setCount(v => v + 7) 44 | expect(effect_spy).toHaveBeenLastCalledWith(17) 45 | expect(un_effect_spy).toBeCalledWith(10) 46 | 47 | setCount(v => v + 9) 48 | expect(get(useCounter).count).toBe(26) 49 | expect(effect_spy).toHaveBeenLastCalledWith(26) 50 | expect(un_effect_spy).toHaveBeenLastCalledWith(17) 51 | 52 | free() 53 | expect(un_effect_spy).toHaveBeenLastCalledWith(26) 54 | }) 55 | 56 | test('Should work few hooks free function', () => { 57 | const useA = () => useState(0) 58 | const useB = () => useState(0) 59 | const useC = () => useState(0) 60 | 61 | get(useA)[1](5) 62 | get(useB)[1](6) 63 | get(useC)[1](7) 64 | 65 | expect(get(useA)[0]).toBe(5) 66 | free(useA) 67 | expect(get(useA)[0]).toBe(0) 68 | 69 | get(useA)[1](15) 70 | 71 | expect(get(useB)[0]).toBe(6) 72 | expect(get(useC)[0]).toBe(7) 73 | free(useB, useC) 74 | 75 | expect(get(useB)[0]).toBe(0) 76 | expect(get(useC)[0]).toBe(0) 77 | 78 | expect(get(useA)[0]).toBe(15) 79 | free() 80 | expect(get(useA)[0]).toBe(0) 81 | }) 82 | 83 | test('Should work on function', () => { 84 | const constr = jest.fn() 85 | const spy = jest.fn() 86 | const useA = () => (constr(), useState(0)) 87 | 88 | on(useA, (state) => spy(state[0])) 89 | expect(constr).toBeCalledTimes(1) 90 | expect(spy).toBeCalledTimes(0) 91 | 92 | get(useA)[1](6) 93 | expect(spy).toBeCalledWith(6) 94 | get(useA)[1](10) 95 | expect(spy).toHaveBeenLastCalledWith(10) 96 | expect(spy).toHaveBeenCalledTimes(2) 97 | 98 | free(useA) 99 | expect(get(useA)[0]).toBe(0) 100 | expect(spy).toHaveBeenCalledTimes(2) 101 | 102 | get(useA)[1](17) 103 | expect(spy).toHaveBeenCalledTimes(2) 104 | 105 | const unsub = on(useA, (state) => spy(state[0])) 106 | get(useA)[1](18) 107 | expect(spy).toHaveBeenLastCalledWith(18) 108 | expect(spy).toHaveBeenCalledTimes(3) 109 | 110 | unsub() 111 | get(useA)[1](19) 112 | expect(spy).toHaveBeenCalledTimes(3) 113 | }) 114 | 115 | test('Should work mock function', () => { 116 | const spy = jest.fn() 117 | const useA = () => useState(10) 118 | const useB = () => useBetween(useA) 119 | 120 | on(useB, (state) => spy(state[0])) 121 | expect(spy).toHaveBeenCalledTimes(0) 122 | 123 | expect(get(useB)[0]).toBe(10) 124 | get(useA)[1](5) 125 | expect(get(useB)[0]).toBe(5) 126 | expect(spy).toHaveBeenLastCalledWith(5) 127 | expect(spy).toHaveBeenCalledTimes(1) 128 | 129 | const unmock = mock(useA, [20]) 130 | expect(get(useB)[0]).toBe(20) 131 | expect(spy).toHaveBeenLastCalledWith(20) 132 | expect(spy).toHaveBeenCalledTimes(2) 133 | 134 | expect(get(useA)).toStrictEqual([20]) 135 | 136 | unmock() 137 | expect(get(useB)[0]).toBe(5) 138 | expect(spy).toHaveBeenLastCalledWith(5) 139 | expect(spy).toHaveBeenCalledTimes(3) 140 | 141 | get(useB)[1](9) 142 | expect(get(useB)[0]).toBe(9) 143 | expect(spy).toHaveBeenLastCalledWith(9) 144 | expect(spy).toHaveBeenCalledTimes(4) 145 | }) 146 | 147 | test('Should work mock function without original function run', () => { 148 | const spy = jest.fn().mockReturnValue(1) 149 | const useA = () => spy() 150 | const unmock = mock(useA, 15) 151 | 152 | expect(get(useA)).toBe(15) 153 | expect(spy).toBeCalledTimes(0) 154 | 155 | mock(useA, 17) 156 | expect(get(useA)).toBe(17) 157 | expect(spy).toBeCalledTimes(0) 158 | 159 | unmock() 160 | expect(get(useA)).toBe(1) 161 | expect(spy).toBeCalledTimes(1) 162 | }) 163 | -------------------------------------------------------------------------------- /tests/subscription.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { render } from '@testing-library/react' 3 | import { clear, useBetween } from '../src' 4 | 5 | 6 | afterEach(clear) 7 | 8 | it('Subscription should be immediately in render phase', async () => { 9 | const useShared = () => { 10 | const [a, setA] = useState(0) 11 | const [b, setB] = useState(0) 12 | useEffect(() => { 13 | setB(1) 14 | }, [a]) 15 | 16 | return { a, setA, b, setB } 17 | } 18 | 19 | const Child = () => { 20 | const { b } = useBetween(useShared) 21 | return {b} 22 | } 23 | 24 | const Parent = () => { 25 | const { a, setA } = useBetween(useShared) 26 | if (a === 0) { 27 | setA(1) 28 | } 29 | return a ? : null 30 | } 31 | 32 | const el = render() 33 | expect((await el.findByTestId('b')).textContent).toBe('1') 34 | }) 35 | -------------------------------------------------------------------------------- /tests/use-between.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useReducer, 4 | useEffect, 5 | useCallback, 6 | useLayoutEffect, 7 | useMemo, 8 | useRef, 9 | useImperativeHandle, 10 | useContext 11 | } from 'react' 12 | import { mount } from 'enzyme' 13 | import { get, useBetween } from '../src' 14 | 15 | test('Should work useState hook', () => { 16 | const useStore = () => useState(0) 17 | 18 | const A = () => { 19 | const [ a ] = useBetween(useStore) 20 | return {a} 21 | } 22 | const B = () => { 23 | const [ , set ] = useBetween(useStore) 24 | return