├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── assets ├── logo.png ├── rendercount.png └── github-social-preview.png ├── .gitignore ├── .prettierrc.js ├── src ├── index.ts ├── useRenderCount.tsx ├── useStableRefTester.tsx ├── useRenderCount.spec.tsx ├── RenderCount.tsx ├── RenderCount.spec.tsx ├── useWhichDepChanged.tsx ├── useStableRefTester.spec.tsx └── useWhichDepChanged.spec.tsx ├── jest.config.js ├── LICENSE ├── .eslintrc.js ├── examples └── Basic.tsx ├── package.json ├── tsconfig.json └── README.md /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-storysource/register'; 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldelcore/react-stable-ref/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/rendercount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldelcore/react-stable-ref/HEAD/assets/rendercount.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | coverage 4 | lib 5 | dist 6 | .site 7 | .DS_Store 8 | storybook-static 9 | -------------------------------------------------------------------------------- /assets/github-social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldelcore/react-stable-ref/HEAD/assets/github-social-preview.png -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | configure(require.context('../examples', true, /\.tsx$/), module); 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 4, 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useStableRefTester } from './useStableRefTester'; 2 | export { default as useWhichDepChanged } from './useWhichDepChanged'; 3 | export { default as RenderCount, RenderCountProps } from './RenderCount'; 4 | export { default as useRenderCount } from './useRenderCount'; 5 | -------------------------------------------------------------------------------- /src/useRenderCount.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | export default function useRenderCount(initialCount = 1) { 4 | const countRef = useRef(initialCount); 5 | 6 | useEffect(() => { 7 | countRef.current++; 8 | }); 9 | 10 | return countRef.current; 11 | } 12 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | use: [ 5 | { 6 | loader: require.resolve('ts-loader'), 7 | options: { 8 | transpileOnly: true, 9 | }, 10 | }, 11 | ], 12 | }); 13 | 14 | config.resolve.extensions.push('.ts', '.tsx'); 15 | 16 | return config; 17 | }; 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': 'ts-jest', 4 | }, 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 6 | testRegex: '^.+\\.spec\\.(ts|tsx|js|jsx)$', 7 | testPathIgnorePatterns: ['/node_modules/', 'lib'], 8 | snapshotSerializers: ['jest-serializer-html-string'], 9 | setupFilesAfterEnv: ['@testing-library/react/cleanup-after-each'], 10 | watchPlugins: [ 11 | 'jest-watch-typeahead/filename', 12 | 'jest-watch-typeahead/testname', 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /src/useStableRefTester.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const useStableRefTester = (timeout: number = 1000) => { 4 | if (process.env.NODE_ENV === 'production') { 5 | console.warn( 6 | 'useStableRefTester is only intended for development purposes only. Please remove from production bundles.', 7 | ); 8 | } 9 | 10 | const [count, setCount] = useState(0); 11 | 12 | useEffect(() => { 13 | const token = setTimeout(() => { 14 | console.log('[useStableRefTester]', `Render count: #${count}`); 15 | 16 | setCount(count + 1); 17 | }, timeout); 18 | 19 | return () => clearTimeout(token); 20 | }, [count, timeout]); 21 | 22 | return count; 23 | }; 24 | 25 | export default useStableRefTester; 26 | -------------------------------------------------------------------------------- /src/useRenderCount.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import useRenderCount from './useRenderCount'; 4 | 5 | const UnstableButton: FC = () => { 6 | const count = useRenderCount(); 7 | return ; 8 | }; 9 | 10 | describe('useRenderCount', () => { 11 | it('should initialize the count at 1', () => { 12 | const { queryByText } = render(); 13 | expect(queryByText('1')).not.toBe(null); 14 | }); 15 | 16 | it('should count every render correctly', () => { 17 | const { rerender, queryByText } = render(); 18 | 19 | rerender(); 20 | rerender(); 21 | rerender(); 22 | 23 | expect(queryByText('4')).not.toBe(null); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/RenderCount.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import useRenderCount from './useRenderCount'; 3 | 4 | export interface RenderCountProps { 5 | initialCount?: number; 6 | count?: number; 7 | } 8 | 9 | const RenderCount: FC = ({ initialCount = 1, count }) => { 10 | const currentCount = useRenderCount(count || initialCount); 11 | 12 | return ( 13 | 28 | {count ? count : currentCount} 29 | 30 | ); 31 | }; 32 | 33 | export default RenderCount; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Del Core 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 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'plugin:react/recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'prettier/@typescript-eslint', 8 | 'plugin:prettier/recommended', 9 | ], 10 | plugins: ['react-hooks'], 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: 'module', 14 | project: './tsconfig.json', 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | }, 19 | rules: { 20 | 'react-hooks/rules-of-hooks': 'error', 21 | 'react-hooks/exhaustive-deps': 'warn', 22 | 'react/prop-types': 'off', 23 | 'react/display-name': 'off', 24 | '@typescript-eslint/explicit-function-return-type': 'off', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | '@typescript-eslint/explicit-member-accessibility': 'off', 27 | '@typescript-eslint/no-non-null-assertion': 'off', 28 | '@typescript-eslint/no-parameter-properties': 'off' 29 | }, 30 | settings: { 31 | react: { 32 | version: 'detect', 33 | }, 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/RenderCount.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import RenderCount from './RenderCount'; 4 | 5 | describe('RenderCount', () => { 6 | it('should initialize the count at 1', () => { 7 | const { queryByText } = render(); 8 | expect(queryByText('1')).not.toBe(null); 9 | }); 10 | 11 | it('should count every render correctly', () => { 12 | const { rerender, queryByText } = render(); 13 | 14 | rerender(); 15 | rerender(); 16 | rerender(); 17 | 18 | expect(queryByText('4')).not.toBe(null); 19 | }); 20 | 21 | it('should render controlled count value', () => { 22 | const { queryByText } = render(); 23 | 24 | expect(queryByText('50')).not.toBe(null); 25 | }); 26 | 27 | it('should render controlled count value', () => { 28 | const { rerender, queryByText } = render(); 29 | 30 | expect(queryByText('2')).not.toBe(null); 31 | rerender(); 32 | expect(queryByText('3')).not.toBe(null); 33 | rerender(); 34 | expect(queryByText('4')).not.toBe(null); 35 | rerender(); 36 | expect(queryByText('5')).not.toBe(null); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/useWhichDepChanged.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | type Dependencies = Record; 4 | 5 | const useWhichDepChanged = ( 6 | dependencies: Dependencies, 7 | onChange?: (changedDeps: Dependencies) => void, 8 | ) => { 9 | if (process.env.NODE_ENV === 'production') { 10 | console.warn( 11 | 'useStableRefTester is only intended for development purposes only. Please remove from production bundles.', 12 | ); 13 | } 14 | 15 | const previousDeps = useRef(); 16 | 17 | useEffect(() => { 18 | if (previousDeps.current) { 19 | const allKeys = Object.keys({ 20 | ...previousDeps.current, 21 | ...dependencies, 22 | }); 23 | const changedDeps: Dependencies = {}; 24 | 25 | allKeys.forEach(key => { 26 | if (previousDeps.current![key] !== dependencies[key]) { 27 | changedDeps[key] = { 28 | from: previousDeps.current![key], 29 | to: dependencies[key], 30 | }; 31 | } 32 | }); 33 | 34 | if (Object.keys(changedDeps).length) { 35 | onChange 36 | ? onChange(changedDeps) 37 | : console.log('[useWhichDepChanged]', changedDeps); 38 | } 39 | } 40 | 41 | previousDeps.current = dependencies; 42 | }); 43 | }; 44 | 45 | export default useWhichDepChanged; 46 | -------------------------------------------------------------------------------- /src/useStableRefTester.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, FC, ReactNode } from 'react'; 2 | import { render, act, cleanup } from '@testing-library/react'; 3 | import useStableRefTester from './useStableRefTester'; 4 | 5 | interface ButtonProps { 6 | onRender: () => void; 7 | children: ReactNode; 8 | } 9 | 10 | jest.useFakeTimers(); 11 | 12 | describe('useStableRefTester', () => { 13 | afterEach(() => cleanup()); 14 | 15 | it('triggers a rerender every at every interval', () => { 16 | const UnstableButton: FC = ({ onRender, children }) => { 17 | const unstableArray = ['1', '2', '3']; 18 | useStableRefTester(); 19 | 20 | useEffect(() => { 21 | onRender(); 22 | }, [unstableArray]); 23 | 24 | return ; 25 | }; 26 | 27 | const onRender = jest.fn(); 28 | 29 | render( 30 | Hello world, 31 | ); 32 | 33 | act(() => { 34 | jest.runOnlyPendingTimers(); 35 | }); 36 | 37 | expect(onRender).toHaveBeenCalledTimes(2); 38 | }); 39 | 40 | it('does not trigger a rerender every at every interval with a stable reference', () => { 41 | const StableButton: FC = ({ onRender, children }) => { 42 | const unstableArray = ['1', '2', '3']; 43 | const stableArrayHash = JSON.stringify(unstableArray); 44 | 45 | useStableRefTester(); 46 | 47 | useEffect(() => { 48 | onRender(); 49 | }, [stableArrayHash]); 50 | 51 | return ; 52 | }; 53 | 54 | const onRender = jest.fn(); 55 | 56 | render(Hello world); 57 | 58 | act(() => { 59 | jest.runOnlyPendingTimers(); 60 | }); 61 | 62 | expect(onRender).toHaveBeenCalledTimes(1); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /examples/Basic.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import React, { FC, ReactNode, useEffect, useState } from 'react'; 3 | 4 | import { useStableRefTester, useWhichDepChanged, RenderCount } from '../src'; 5 | 6 | interface ButtonProps { 7 | onClick: () => void; 8 | children: ReactNode; 9 | } 10 | 11 | const UnstableButton: FC = ({ onClick, children }) => { 12 | const unstableArray = ['1', '2', '3']; 13 | 14 | useStableRefTester(); 15 | useWhichDepChanged({ unstableArray }); 16 | 17 | useEffect(() => { 18 | console.warn('I should not be called'); 19 | }, [unstableArray]); 20 | 21 | return ( 22 | 26 | ); 27 | }; 28 | 29 | const StableButton: FC = ({ onClick, children }) => { 30 | const stableArray = ['1', '2', '3']; 31 | const [renderCount, setRenderCount] = useState(0); 32 | 33 | useStableRefTester(); 34 | useWhichDepChanged({ stableArray: JSON.stringify(stableArray) }); 35 | 36 | useEffect(() => { 37 | console.warn('I should not be called'); 38 | setRenderCount(renderCount + 1); 39 | // eslint-disable-next-line react-hooks/exhaustive-deps 40 | }, [JSON.stringify(stableArray)]); 41 | 42 | return ( 43 | 47 | ); 48 | }; 49 | 50 | storiesOf('Basic', module).add('Visualize unstable references', () => { 51 | const [isStable, setIsStable] = useState(false); 52 | 53 | return ( 54 | 55 |

Open the console to view output

56 |

57 | Render count: 58 |

59 | {isStable ? ( 60 | setIsStable(!isStable)}> 61 | Stable button 😁 62 | 63 | ) : ( 64 | setIsStable(!isStable)}> 65 | Unstable button 😵 66 | 67 | )} 68 |
69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-stable-ref", 3 | "version": "0.5.2", 4 | "description": "An assortment of utilities for testing against unstable references in React", 5 | "main": "lib/index.js", 6 | "author": "Daniel Del Core", 7 | "license": "MIT", 8 | "typings": "lib/index.d.ts", 9 | "files": [ 10 | "lib" 11 | ], 12 | "scripts": { 13 | "start": "npm run dev", 14 | "dev": "start-storybook -p 9000 -c .storybook -s ./assets", 15 | "predeploy-storybook": "build-storybook -s assets", 16 | "deploy-storybook": "storybook-to-ghpages", 17 | "build": "tsc", 18 | "typecheck": "tsc --noEmit", 19 | "test": "jest", 20 | "test:watch": "jest --watch", 21 | "test:coverage": "jest --coverage", 22 | "lint": "eslint --config .eslintrc.js --ext tsx,ts ./src/ ./examples ", 23 | "lint:fix": "npm run lint -- --fix", 24 | "preversion": "npm run lint && npm run typecheck && npm run test", 25 | "version": "npm run build", 26 | "postversion": "git push && git push --tags", 27 | "postpublish": "npm run deploy-storybook" 28 | }, 29 | "homepage": "https://github.com/danieldelcore/react-stable-ref#readme", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/danieldelcore/react-stable-ref.git" 33 | }, 34 | "keywords": [ 35 | "react", 36 | "hooks", 37 | "hook", 38 | "useRef", 39 | "useMemo" 40 | ], 41 | "bugs": { 42 | "url": "https://github.com/danieldelcore/react-stable-ref/issues" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.7.5", 46 | "@storybook/addon-storysource": "^5.2.8", 47 | "@storybook/addons": "^5.2.8", 48 | "@storybook/react": "^5.2.8", 49 | "@storybook/source-loader": "^5.2.8", 50 | "@storybook/storybook-deployer": "^2.8.1", 51 | "@testing-library/react": "^8.0.5", 52 | "@types/jest": "^23.3.13", 53 | "@types/jsdom": "^12.2.3", 54 | "@types/react": "^16.8.1", 55 | "@types/react-dom": "^16.8.2", 56 | "@types/storybook__react": "^4.0.0", 57 | "@typescript-eslint/eslint-plugin": "^1.4.2", 58 | "@typescript-eslint/parser": "^1.4.2", 59 | "babel-loader": "^8.0.6", 60 | "eslint": "^5.15.1", 61 | "eslint-config-prettier": "^4.1.0", 62 | "eslint-plugin-prettier": "^3.0.1", 63 | "eslint-plugin-react": "^7.12.4", 64 | "eslint-plugin-react-hooks": "^1.5.0", 65 | "jest": "^24.0.0", 66 | "jest-serializer-html-string": "^1.0.1", 67 | "jest-watch-typeahead": "^0.4.2", 68 | "jsdom": "^14.0.0", 69 | "prettier": "^1.16.4", 70 | "react": "^16.8.1", 71 | "react-dom": "^16.8.4", 72 | "react-test-renderer": "^16.13.0", 73 | "ts-jest": "^24.0.2", 74 | "ts-loader": "^6.2.1", 75 | "typescript": "^3.3.1" 76 | }, 77 | "peerDependencies": { 78 | "react": ">=16.8.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/useWhichDepChanged.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactNode } from 'react'; 2 | import { render, cleanup } from '@testing-library/react'; 3 | 4 | import useWhichDepChanged from './useWhichDepChanged'; 5 | 6 | interface ButtonProps { 7 | data: any; 8 | children: ReactNode; 9 | onChange?: (changedDeps: Record) => void; 10 | } 11 | 12 | const Button: FC = ({ data, children, onChange }) => { 13 | useWhichDepChanged({ data }, onChange); 14 | 15 | return ; 16 | }; 17 | 18 | describe('useWhichDepChanged', () => { 19 | let consoleMock: jest.Mock; 20 | 21 | beforeEach(() => { 22 | consoleMock = jest 23 | .spyOn(global.console, 'log') 24 | .mockImplementation(() => {}); 25 | }); 26 | 27 | afterAll(() => { 28 | jest.resetAllMocks(); 29 | cleanup(); 30 | }); 31 | 32 | it('emits changed dependencies to the console', () => { 33 | let testValue = 'Foo'; 34 | 35 | const { rerender } = render( 36 | , 37 | ); 38 | 39 | testValue = 'Bar'; 40 | 41 | rerender(); 42 | 43 | expect(consoleMock).toHaveBeenCalledWith('[useWhichDepChanged]', { 44 | data: { from: 'Foo', to: 'Bar' }, 45 | }); 46 | expect(consoleMock).toHaveBeenCalledTimes(1); 47 | }); 48 | 49 | it('detects changed dependencies', () => { 50 | const onChange = jest.fn(); 51 | let testValue = 'Foo'; 52 | 53 | const { rerender } = render( 54 | , 57 | ); 58 | 59 | testValue = 'Bar'; 60 | 61 | rerender( 62 | , 65 | ); 66 | 67 | expect(onChange).toHaveBeenCalledWith({ 68 | data: { 69 | from: 'Foo', 70 | to: 'Bar', 71 | }, 72 | }); 73 | expect(onChange).toHaveBeenCalledTimes(1); 74 | }); 75 | 76 | it('detects unstable dependencies', () => { 77 | const onChange = jest.fn(); 78 | let testArray = [1, 2, 3]; 79 | 80 | const { rerender } = render( 81 | , 84 | ); 85 | 86 | testArray = [1, 2, 3]; 87 | 88 | rerender( 89 | , 92 | ); 93 | 94 | expect(onChange).toHaveBeenCalledWith({ 95 | data: { 96 | from: testArray, 97 | to: testArray, 98 | }, 99 | }); 100 | expect(onChange).toHaveBeenCalledTimes(1); 101 | }); 102 | 103 | it('should not detect unchanged dependencies', () => { 104 | const onChange = jest.fn(); 105 | let testValue = 'Foo'; 106 | 107 | const { rerender } = render( 108 | , 111 | ); 112 | 113 | rerender( 114 | , 117 | ); 118 | 119 | expect(onChange).not.toHaveBeenCalled(); 120 | }); 121 | 122 | it('should not detect stable dependencies', () => { 123 | const onChange = jest.fn(); 124 | let testValue = 'Foo'; 125 | 126 | const { rerender } = render( 127 | , 130 | ); 131 | 132 | testValue = 'Foo'; 133 | 134 | rerender( 135 | , 138 | ); 139 | 140 | expect(onChange).not.toHaveBeenCalled(); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["es6", "dom"], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "outDir": "./", /* Redirect output structure to the directory. */ 15 | "outDir": "lib", 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | 51 | /* Source Map Options */ 52 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 53 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 54 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 55 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 56 | 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | }, 61 | "include": [ 62 | "src/**/*" 63 | ], 64 | "exclude": [ 65 | "node_modules" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Test stable references 3 |

4 | 5 | # react-stable-ref 🤷‍♂️ 6 | 7 | [![min](https://img.shields.io/bundlephobia/min/react-stable-ref.svg)](https://www.npmjs.com/package/react-stable-ref) 8 | [![npm](https://img.shields.io/npm/v/react-stable-ref.svg)](https://www.npmjs.com/package/react-stable-ref) 9 | [![Downloads per month](https://img.shields.io/npm/dm/react-stable-ref.svg)](https://www.npmjs.com/package/react-stable-ref) 10 | 11 | Your stable reference utility library with everything you need to test, visualize and protect against the dreaded unintentional rerender 😱 12 | 13 | [Try it here](https://danieldelcore.github.io/react-stable-ref/) 14 | 15 | ## Get started 🏗 16 | 17 | **Installation** 18 | 19 | `npm install --save react-stable-ref` or `yarn add react-stable-ref` 20 | 21 | ## Example 22 | 23 | ```jsx 24 | const UnstableButton: FC = ({ onClick, children }) => { 25 | // Unstable reference (unstableArray is reassigned on every render) 26 | const unstableArray = ['1', '2', '3']; 27 | const stableValue = 'Im stable because im a string'; 28 | 29 | useStableRefTester(); // Triggers re-renders every second 30 | useWhichDepChanged({ unstableArray, stableValue }); 31 | /** 32 | * Will output the following to the console (or onChange if you pass it in) 33 | * 34 | * > [useWhichDepChanged]: { unstableArray: { from: [1, 2, 3]; to: [1, 2, 3]}} 35 | */ 36 | 37 | return ( 38 | 41 | ); 42 | }; 43 | ``` 44 | 45 | ## Motivation 🧠 46 | 47 | It's not always obvious when unstable references are passed into hooks such as `useEffect`. This can cause unnecessary rerenders, which when left unchecked can decrease the performance of your app, cause jank and ultimately degrade your user's experience 😭. 48 | 49 | Thankfully the React team have already thought about this and provided [lint rules to help](https://www.npmjs.com/package/eslint-plugin-react-hooks) 🥰. But what if you're passing objects and arrays into dependency arrays which are not 'deeply' compared? How can you know for sure? 50 | 51 | `react-stable-ref` fills that gap and provides an assortment of utilities to help test, visualize and protect against the dreaded re-render 😱. 52 | 53 | ## API 🤖 54 | 55 | ### `useStableRefTester()` 56 | 57 | A **development only** hook, which increments state over a predefined interval, triggering rerenders in your component. 58 | 59 | **Arguments:** 60 | 61 | - timeout: `Number` Timeout between rerenders 62 | 63 | **Returns:** 64 | 65 | count: `Number` 66 | 67 | **Example:** 68 | 69 | ```jsx 70 | const UnstableButton = ({ children }) => { 71 | const myArray = ['1', '2', '3']; 72 | 73 | useStableRefTester(); 74 | 75 | useEffect(() => { 76 | console.warn('I should not be called on every render'); 77 | }, [myArray]); 78 | 79 | return ; 80 | }; 81 | ``` 82 | 83 | ### `useWhichDepChanged()` 84 | 85 | A **development only** hook which emits (via console) which prop triggered an update. Useful when you are unsure which property changed in a `useEffect` dependency array. 86 | 87 | _Inspired by_: [useWhyDidYouUpdate](https://usehooks.com/useWhyDidYouUpdate/) 88 | 89 | **Arguments:** 90 | 91 | - dependencies: `Object` A dependency object which mirrors the dependency array of the hook you are trying to test 92 | - onChange(changedDeps): `(changedDeps: Obj) => void` A callback which is fired when a dependency is changed. 93 | 94 | **Returns:** 95 | 96 | `void` 97 | 98 | **Example:** 99 | 100 | ```jsx 101 | const UnstableButton = ({ children }) => { 102 | const myArray = ['1', '2', '3']; 103 | 104 | useWhichDepChanged({ myArray, children }, onChange(changedDeps) => { 105 | console.log('UnstableButton: ', changedDeps); // UnstableButton: myArray 106 | }); 107 | 108 | return ; 109 | }; 110 | ``` 111 | 112 | ### `useRenderCount()` 113 | 114 | A hook which returns how many times it has been rendered. 115 | 116 | **Arguments:** 117 | 118 | - initialCount: `Number` Initial counter value 119 | 120 | **Returns:** 121 | 122 | - count: `Number` Current counter value 123 | 124 | **Example:** 125 | 126 | ```jsx 127 | const RenderCounter = () => { 128 | const count = useRenderCount(); 129 | 130 | return ; 131 | }; 132 | ``` 133 | 134 | ### `` 135 | 136 | A visual component that keeps track of the number of renders that have occurred. 137 | 138 |

139 | Render count component 140 |

141 | 142 | **Props:** 143 | 144 | - initialCount: `Number` Initial counter value 145 | - count: `Number` Provide a count for a controlled API 146 | 147 | ### `useDeeplyComparedEffect()` 148 | 149 | _Coming soon..._ 150 | 151 | A react hook for deeply comparing objects and arrays passed into its dependency array. 152 | 153 | ### `useCustomComparedEffect()` 154 | 155 | _Coming soon..._ 156 | 157 | A react hook to allow you to provide custom methods used to comparing dependencies and trigger an effect. 158 | 159 | ## Thanks 😍 160 | 161 | Huge thank you to [Pablo Stanley](https://twitter.com/pablostanley) and contributors of [Open Peeps](https://www.openpeeps.com/?ref=react-stable-ref) for the logo. 162 | 163 | ## Resources 164 | 165 | - [Introducing Hooks](https://reactjs.org/docs/hooks-intro.html) 166 | - [Making Sense of React Hooks](https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889) 167 | - [When to useMemo and useCallback](https://kentcdodds.com/blog/usememo-and-usecallback/) 168 | --------------------------------------------------------------------------------