├── .gitignore ├── .travis.yml ├── src └── index.tsx ├── tsconfig.json ├── .all-contributorsrc ├── .github └── workflows │ └── main.yml ├── LICENSE ├── package.json ├── test └── index.test.tsx └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | os: linux 4 | 5 | node_js: 6 | - '12' 7 | 8 | jobs: 9 | include: 10 | - stage: test 11 | script: yarn test 12 | - stage: publish to npm 13 | deploy: 14 | skip_cleanup: true 15 | provider: npm 16 | email: $NPM_EMAIL 17 | api_token: $NPM_TOKEN 18 | on: 19 | tags: true 20 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | const UNINITIALIZED = Symbol('useInstance_uninitialized'); 3 | 4 | type useInstanceArg = (() => any) | any; 5 | 6 | const useInstance = ( 7 | initialValueOrFunction: useInstanceArg = {} 8 | ): T => { 9 | const ref = useRef(UNINITIALIZED as any); 10 | if (ref.current === UNINITIALIZED) { 11 | ref.current = 12 | typeof initialValueOrFunction === 'function' 13 | ? initialValueOrFunction() 14 | : initialValueOrFunction; 15 | } 16 | return ref.current; 17 | }; 18 | 19 | export default useInstance; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "*": ["src/*", "node_modules/*"] 19 | }, 20 | "jsx": "react", 21 | "esModuleInterop": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "use-instance", 3 | "projectOwner": "donavon", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "contributors": [ 12 | { 13 | "login": "donavon", 14 | "name": "Donavon West", 15 | "avatar_url": "https://avatars3.githubusercontent.com/u/887639?v=4", 16 | "profile": "http://donavon.com", 17 | "contributions": [ 18 | "ideas", 19 | "infra", 20 | "maintenance", 21 | "review", 22 | "code", 23 | "design" 24 | ] 25 | }, 26 | { 27 | "login": "derek-rmd", 28 | "name": "Derek Jones", 29 | "avatar_url": "https://avatars0.githubusercontent.com/u/46328094?v=4", 30 | "profile": "https://github.com/derek-rmd", 31 | "contributions": [ 32 | "code" 33 | ] 34 | } 35 | ], 36 | "contributorsPerLine": 7, 37 | "skipCi": true 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Lint 30 | run: yarn lint 31 | env: 32 | CI: true 33 | 34 | - name: Test 35 | run: yarn test --ci --coverage --maxWorkers=2 36 | env: 37 | CI: true 38 | 39 | - name: Build 40 | run: yarn build 41 | env: 42 | CI: true 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present Donavon West 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@use-it/instance", 3 | "publishConfig": { 4 | "access": "public", 5 | "tag": "latest" 6 | }, 7 | "version": "0.3.0", 8 | "description": "A custom React Hook that provides a sensible alternative to `useRef` for storing instance variables.", 9 | "license": "MIT", 10 | "main": "dist/index.js", 11 | "typings": "dist/index.d.ts", 12 | "files": [ 13 | "dist", 14 | "src" 15 | ], 16 | "engines": { 17 | "node": ">=10" 18 | }, 19 | "scripts": { 20 | "start": "tsdx watch", 21 | "build": "tsdx build", 22 | "test": "tsdx test --passWithNoTests", 23 | "lint": "tsdx lint", 24 | "prepare": "tsdx build" 25 | }, 26 | "peerDependencies": { 27 | "react": ">=16.8.0" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "tsdx lint" 32 | } 33 | }, 34 | "prettier": { 35 | "printWidth": 80, 36 | "semi": true, 37 | "singleQuote": true, 38 | "trailingComma": "es5" 39 | }, 40 | "author": "Donavon West (https://github.com/donavon)", 41 | "module": "dist/use-instance.esm.js", 42 | "devDependencies": { 43 | "@testing-library/jest-dom": "^5.11.3", 44 | "@testing-library/react-hooks": "^3.4.1", 45 | "@types/react": "^16.9.46", 46 | "@types/react-dom": "^16.9.8", 47 | "husky": "^4.2.5", 48 | "react": "^16.13.1", 49 | "react-dom": "^16.13.1", 50 | "react-test-renderer": "^16.13.1", 51 | "tsdx": "^0.13.2", 52 | "tslib": "^2.0.1", 53 | "typescript": "^3.9.7" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import useInstance from '../src'; 2 | 3 | import { renderHook, cleanup } from '@testing-library/react-hooks'; 4 | import '@testing-library/jest-dom/extend-expect'; 5 | 6 | afterEach(cleanup); 7 | 8 | describe('useInstance', () => { 9 | test('import useInstance from "@use-it/instance"', () => { 10 | expect(typeof useInstance).toBe('function'); 11 | }); 12 | 13 | test('will default to an empty object if passed no parameters`', () => { 14 | const { result } = renderHook(() => useInstance()); 15 | expect(result.current).toEqual({}); 16 | }); 17 | 18 | test('accepts an optional object`', () => { 19 | const { result } = renderHook(() => useInstance({ foo: 'foo' })); 20 | expect(result.current).toEqual({ foo: 'foo' }); 21 | }); 22 | 23 | test('or an optional "lazy" initialization function that returns an object`', () => { 24 | const { result } = renderHook(() => useInstance(() => ({ foo: 'foo' }))); 25 | expect(result.current).toEqual({ foo: 'foo' }); 26 | }); 27 | 28 | test('the "lazy" initialization function is only called once`', () => { 29 | const fn = jest.fn(() => false); 30 | const { result, rerender } = renderHook(() => useInstance(fn)); 31 | expect(result.current).toEqual(false); 32 | rerender(); 33 | expect(fn).toBeCalledTimes(1); 34 | }); 35 | 36 | test('will return the same instance object for every render`', () => { 37 | const { result, rerender } = renderHook(() => useInstance()); 38 | const initialObject = result.current; 39 | 40 | rerender(); 41 | 42 | expect(result.current).toEqual(initialObject); 43 | }); 44 | 45 | test('will return the same instance object for every render when using a function`', () => { 46 | const { result, rerender } = renderHook(() => useInstance(() => ({}))); 47 | const initialObject = result.current; 48 | 49 | rerender(); 50 | 51 | expect(result.current).toEqual(initialObject); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @use-it/instance 🐘 2 | 3 | A custom React Hook that provides a sensible alternative to `useRef` for storing instance variables. 4 | 5 | [![npm version](https://badge.fury.io/js/%40use-it%2Finstance.svg)](https://badge.fury.io/js/%40use-it%2Finstance) 6 | [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors) 7 | 8 | ## Why? 9 | 10 | `useRef` is weird. The official React docs say: 11 | 12 | > `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument (`initialValue`). The returned object will persist for the full lifetime of the component. 13 | 14 | > Note that useRef() is useful for more than the ref attribute. It’s handy for keeping any mutable value around similar to how you’d use instance fields in classes. 15 | 16 | The fact that you have to access it via `.current` is strange. 17 | The fact that the React docs call out that you can use it for more than 18 | ref attributes is telling. 19 | They have to know that `useRef` is confusing too. 20 | 21 | That's why I created `useInstance`. 22 | You treat the object returned by `useInstance` just like you would `this` in a class component, 23 | so you're already familiar with how it works. 24 | 25 | So… Use `useRef` if you're dealing with actual DOM elements—use 26 | `useInstance` for instance properties and methods. 27 | 28 | Some may say _"Six of one, hald dozen of another,"_ and they could be right. 29 | But if you're in the "half-dozen" camp, `useInstance` might well be for you! 30 | 31 | Want more evidence that `useRef` is weird? 32 | In the latest [React v17 RC](https://reactjs.org/blog/2020/08/10/react-v17-rc.html#potential-issues) under "Potential Issues" it shows that this code could break. 33 | 34 | ```js 35 | useEffect(() => { 36 | someRef.current.someSetupMethod(); 37 | return () => { 38 | someRef.current.someCleanupMethod(); 39 | }; 40 | }); 41 | ``` 42 | 43 | The solution that they are suggesting (below) is to use an instance variable, which is exactly what `useInstance` is doing. Had React implimented `useInstance` this "potential issue" would never have been raised. 44 | 45 | ```js 46 | useEffect(() => { 47 | const instance = someRef.current; 48 | instance.someSetupMethod(); 49 | return () => { 50 | instance.someCleanupMethod(); 51 | }; 52 | }); 53 | ``` 54 | 55 | ## Installation 56 | 57 | ```bash 58 | $ npm i @use-it/instance 59 | ``` 60 | 61 | or 62 | 63 | ```bash 64 | $ yarn add @use-it/instance 65 | ``` 66 | 67 | ## Usage 68 | 69 | Here is a basic setup. Most people will name the returned object 70 | `self` or `that` or `instance`, but you can call it anything you'd like. 71 | 72 | ```js 73 | import useInstance from '@use-it/instance'; 74 | 75 | // Then from within your function component 76 | const instance = useInstance(initialInstance); 77 | ``` 78 | 79 | ### Parameters 80 | 81 | Here are the parameters that you can use. (\* = optional) 82 | 83 | | Parameter | Description | 84 | | :---------------- | :--------------------------------------------------------------------------------------------------------------------------------- | 85 | | `initialInstance` | Is an object or a function that returns an object that represents the initial instance value. Defaults to an empty object literal. | 86 | 87 | ### Return 88 | 89 | The return value is an object that WILL NOT change on subsequent calls to your function component. 90 | Use it just like you would to create instance properties and methods in a class component. 91 | 92 | ## Examples 93 | 94 | ### A replacement for `useRef` 95 | 96 | Here's an example where you might use `useRef`. 97 | Within the closure of the `useEffect`, if we simply reference 98 | `callback` directly, we will see `callback` as it was during the 99 | creation of the function. 100 | Instead we must make it "live" throughout many render cycles. 101 | 102 | ```js 103 | function useInterval(callback, delay) { 104 | const savedCallback = useRef(); 105 | savedCallback.current = callback; 106 | 107 | useEffect(() => { 108 | function tick() { 109 | savedCallback.current(); 110 | } 111 | if (delay !== null) { 112 | const id = setInterval(tick, delay); 113 | return () => clearInterval(id); 114 | } 115 | }, [delay]); 116 | } 117 | ``` 118 | 119 | And here's the same code using `useInstance`. 120 | It has a more familiar class component-like syntax. 121 | Again, think of `instance` as `this`. 122 | 123 | ```js 124 | function useInterval(callback, delay) { 125 | const instance = useInstance(); 126 | instance.savedCallback = callback; 127 | 128 | useEffect(() => { 129 | function tick() { 130 | instance.savedCallback(); 131 | } 132 | if (delay !== null) { 133 | const id = setInterval(tick, delay); 134 | return () => clearInterval(id); 135 | } 136 | }, [delay]); 137 | } 138 | ``` 139 | 140 | ### A replacement for `useCallback`/`useMemo` 141 | 142 | You can also use `useInstance` in place of `useCallback` and `useMemo` for some cases. 143 | 144 | Take for instance (pardon the pun), this simple up/down counter example. 145 | You might use `useCallback` to ensure that the pointers 146 | to the `increment` and `decrement` function didn't change. 147 | 148 | ```js 149 | const useCounter = initialCount => { 150 | const [count, setCount] = useState(initialCount); 151 | const increment = useCallback(() => setCount(c => c + 1)); 152 | const decrement = useCallback(() => setCount(c => c - 1)); 153 | 154 | return { 155 | count, 156 | increment, 157 | decrement, 158 | }; 159 | }; 160 | ``` 161 | 162 | Or, you could store them in the instance, much like you 163 | might have done with a class component. 164 | 165 | ```js 166 | const useCounter = initialCount => { 167 | const [count, setCount] = useState(initialCount); 168 | const self = useInstance({ 169 | increment: () => setCount(c => c + 1), 170 | decrement: () => setCount(c => c - 1), 171 | }); 172 | 173 | return { 174 | count, 175 | ...self, 176 | }; 177 | }; 178 | ``` 179 | 180 | What is the benefit of keeping functions in instances variable 181 | instead of using `useCallback`? 182 | This is from the official Hooks documentation. 183 | 184 | > In the future, React may choose to “forget” some previously memoized values and recalculate them on next render. 185 | 186 | Instance variables never forget. 🐘 187 | 188 | ### Lazy initialization 189 | 190 | If computing the `initialInstance` is costly, you may pass a function that returns an `initialInstance`. 191 | `useInstance` will only call the function on mount. 192 | 193 | Not that creating two functions is expensive, 194 | but we could re-write the example above using a lazy initializer, like this. 195 | 196 | ```js 197 | // this is only called once, on mount, per component instance 198 | const getInitialInstance = setCount => ({ 199 | increment: () => setCount(c => c + 1), 200 | decrement: () => setCount(c => c - 1), 201 | }); 202 | 203 | const useCounter = initialCount => { 204 | const [count, setCount] = useState(initialCount); 205 | const self = useInstance(getInitialInstance(setCount)); 206 | 207 | return { 208 | count, 209 | ...self, 210 | }; 211 | }; 212 | ``` 213 | 214 | Notice that we moved `getInitialInstance` into a static fucntion _outside_ of `useCounter`. 215 | This helps to reduce the complexity of `useCounter`. 216 | An added side effect is that `getInitialInstance` is now highly testable. 217 | 218 | You might even take this one step further and refactor your methods 219 | into it's own custom Hook. Isn't coding fun? 😊 220 | 221 | ```js 222 | const getInitialInstance = setCount => ({ 223 | increment: () => setCount(c => c + 1), 224 | decrement: () => setCount(c => c - 1), 225 | }); 226 | 227 | const useCounterMethods = setCount => useInstance(getInitialInstance(setCount)); 228 | 229 | const useCounter = initialCount => { 230 | const [count, setCount] = useState(initialCount); 231 | const methods = useCounterMethods(setCount); 232 | 233 | return { 234 | count, 235 | ...methods, 236 | }; 237 | }; 238 | ``` 239 | 240 | ## Live demo 241 | 242 | You can view/edit the "you clicked" sample code above on CodeSandbox. 243 | 244 | [![Edit demo app on CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/6wqqr4y9oz) 245 | 246 | ## License 247 | 248 | **[MIT](LICENSE)** Licensed 249 | 250 | ## Contributors 251 | 252 | Thanks goes to these wonderful people ([emoji key](https://github.com/all-contributors/all-contributors#emoji-key)): 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 |

Donavon West

🤔 🚇 🚧 👀 💻 🎨

Derek Jones

💻
263 | 264 | 265 | 266 | 267 | 268 | 269 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 270 | --------------------------------------------------------------------------------