├── index.d.ts ├── .travis.yml ├── .gitignore ├── index.js ├── tsconfig.json ├── test ├── _helpers │ ├── RenderCounter.ts │ ├── render.tsx │ └── StateProvider.ts └── integration.test.tsx ├── tslint.json ├── package.json ├── src └── index.ts └── README.md /index.d.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./dist/index" 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node # Latest 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | node_modules/ 3 | .DS_Store 4 | Thumbs.db 5 | *.log 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const useInlineMemo = require("./dist/index").default 2 | 3 | module.exports = useInlineMemo 4 | module.exports.default = useInlineMemo 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "lib": ["es5"], 8 | "outDir": "dist", 9 | "declaration": true, 10 | "strict": true 11 | }, 12 | "include": [ 13 | "./src/index.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /test/_helpers/RenderCounter.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | interface Counter { 4 | count: number 5 | } 6 | 7 | interface Props { 8 | children?: React.ReactNode 9 | counter: Counter 10 | } 11 | 12 | function RenderCounter(props: Props) { 13 | props.counter.count++ 14 | return props.children 15 | } 16 | 17 | export default RenderCounter as React.ComponentType 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-config-prettier" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "interface-name": [ 9 | true, 10 | "never-prefix" 11 | ], 12 | "no-implicit-dependencies": [ 13 | true, 14 | "dev" 15 | ], 16 | "no-object-literal-type-assertion": false, 17 | "no-submodule-imports": false, 18 | "object-literal-sort-keys": false, 19 | "only-arrow-functions": false, 20 | "semicolon": false, 21 | "unified-signatures": false 22 | }, 23 | "rulesDirectory": [] 24 | } 25 | -------------------------------------------------------------------------------- /test/_helpers/render.tsx: -------------------------------------------------------------------------------- 1 | import { JSDOM } from "jsdom" 2 | import * as React from "react" 3 | import * as ReactDOM from "react-dom" 4 | import StateProvider from "./StateProvider" 5 | 6 | const dom = new JSDOM() 7 | ;(global as any).window = dom.window 8 | 9 | export default function render(states: State[], renderFn: (state: State) => React.ReactElement) { 10 | const mountPoint = dom.window.document.createElement("div") 11 | dom.window.document.body.appendChild(mountPoint) 12 | 13 | return new Promise(resolve => { 14 | ReactDOM.render( 15 | 16 | {renderFn} 17 | , 18 | mountPoint 19 | ) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /test/_helpers/StateProvider.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | interface Props { 4 | children: (currentState: State) => React.ReactElement 5 | onCompletion?: () => void 6 | states: State[] 7 | } 8 | 9 | const StateProvider = (props: Props) => { 10 | const { onCompletion = () => undefined } = props 11 | 12 | const [stateIndex, setStateIndex] = React.useState(0) 13 | const currentState = props.states[stateIndex] 14 | 15 | React.useEffect(() => { 16 | const interval = setInterval(() => { 17 | setStateIndex(prevIndex => { 18 | if (prevIndex < props.states.length - 1) { 19 | return prevIndex + 1 20 | } else { 21 | clearInterval(interval) 22 | setTimeout(() => onCompletion(), 50) 23 | return prevIndex 24 | } 25 | }) 26 | }, 50) 27 | return () => clearInterval(interval) 28 | }, []) 29 | 30 | return props.children(currentState) 31 | } 32 | 33 | export default StateProvider 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-inline-memo", 3 | "version": "0.2.0", 4 | "license": "MIT", 5 | "description": "React hook for memoizing values inline in JSX.", 6 | "author": "Andy Wermke (https://github.com/andywer)", 7 | "repository": "github:andywer/use-inline-memos", 8 | "scripts": { 9 | "build": "tsc", 10 | "test": "ava", 11 | "posttest": "tslint --project .", 12 | "prepare": "npm run build" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "hook", 17 | "memo", 18 | "callback", 19 | "listener", 20 | "styles" 21 | ], 22 | "peerDependencies": { 23 | "react": ">= 16.7" 24 | }, 25 | "files": [ 26 | "dist/**", 27 | "*.d.ts", 28 | "*.js" 29 | ], 30 | "ava": { 31 | "compileEnhancements": false, 32 | "extensions": [ 33 | "ts", 34 | "tsx" 35 | ], 36 | "files": [ 37 | "./test/*.test.ts", 38 | "./test/*.test.tsx" 39 | ], 40 | "require": [ 41 | "ts-node/register" 42 | ], 43 | "serial": true 44 | }, 45 | "prettier": { 46 | "semi": false, 47 | "printWidth": 100 48 | }, 49 | "lint-staged": { 50 | "*": [ 51 | "prettier --write", 52 | "git add" 53 | ] 54 | }, 55 | "devDependencies": { 56 | "@types/jsdom": "^12.2.4", 57 | "@types/react-dom": "^16.8.5", 58 | "ava": "^5.1.0", 59 | "jsdom": "^16.5.0", 60 | "lint-staged": "^9.2.1", 61 | "prettier": "^1.18.2", 62 | "react": "^16.9.0", 63 | "react-dom": "^16.9.0", 64 | "ts-node": "^8.3.0", 65 | "tslint": "^5.18.0", 66 | "tslint-config-prettier": "^1.18.0", 67 | "typescript": "^3.5.3" 68 | }, 69 | "dependencies": { 70 | "@types/react": "^16.9.1" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /test/integration.test.tsx: -------------------------------------------------------------------------------- 1 | import test from "ava" 2 | import * as React from "react" 3 | import useInlineMemo from "../src/index" 4 | import render from "./_helpers/render" 5 | import RenderCounter from "./_helpers/RenderCounter" 6 | 7 | const benchmarkMessages: string[] = [] 8 | 9 | function benchmark(label: string, Component: React.FunctionComponent) { 10 | return (props: Props) => { 11 | const startTime = process.hrtime.bigint() 12 | const rendered = Component(props) 13 | const measuredTime = process.hrtime.bigint() - startTime 14 | benchmarkMessages.push(`${label} rendering took ${(Number(measuredTime) / 1e6).toFixed(3)}ms`) 15 | return rendered 16 | } 17 | } 18 | 19 | test.after(() => benchmarkMessages.forEach(message => console.log(message))) 20 | 21 | test("can memo inline styles", async t => { 22 | const counter = { count: 0 } 23 | 24 | const StyledComponent = React.memo((props: { style: React.CSSProperties }) => { 25 | return ( 26 | 27 |
28 |
29 | ) 30 | }) 31 | const StylingComponent = React.memo(benchmark(t.title, (props: { color: string }) => { 32 | const memo = useInlineMemo() 33 | return ( 34 | 35 | ) 36 | })) 37 | 38 | await render(["red", "red", "green", "red", "green"], color => ( 39 | 40 | )) 41 | 42 | t.is(counter.count, 3, "Should render 3x, not 5x as without memo()-ing.") 43 | }) 44 | 45 | test("can memo inline styles with explicit memo identifiers", async t => { 46 | const counter = { count: 0 } 47 | 48 | const StyledComponent = React.memo((props: { style: React.CSSProperties }) => { 49 | return ( 50 | 51 |
52 |
53 | ) 54 | }) 55 | const StylingComponent = React.memo(benchmark(t.title, (props: { color: string }) => { 56 | const memo = useInlineMemo("style") 57 | return ( 58 | 59 | ) 60 | })) 61 | 62 | await render(["red", "red", "green", "red", "green"], color => ( 63 | 64 | )) 65 | 66 | t.is(counter.count, 3, "Should render 3x, not 5x as without memo()-ing.") 67 | }) 68 | 69 | test("can memo event listeners", async t => { 70 | const counter = { count: 0 } 71 | 72 | const Button = React.memo((props: { children: React.ReactNode; onClick: () => void }) => { 73 | return ( 74 | 75 | 76 | 77 | ) 78 | }) 79 | const Sample = benchmark(t.title, () => { 80 | const memo = useInlineMemo() 81 | return ( 82 | 85 | ) 86 | }) 87 | 88 | await render([1, 2, 3], () => ( 89 | 90 | )) 91 | 92 | t.is(counter.count, 1, "Should render 1x, not 3x as without memo()-ing.") 93 | }) 94 | 95 | test("fails if same memoizer function is called twice", async t => { 96 | const Sample = () => { 97 | const memo = useInlineMemo() 98 | return ( 99 |
100 |
101 |
102 |
103 | ) 104 | } 105 | 106 | await t.throwsAsync(render([1, 2, 3], () => ( 107 | 108 | ))) 109 | }) 110 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const $calls = Symbol("calls") 4 | const inProduction = typeof process !== "undefined" && process.env && process.env.NODE_ENV === "production" 5 | 6 | type MemoFunction = (value: T, deps: any[]) => T 7 | 8 | type MapToMemoizers = { 9 | [key in Key]: MemoFunction 10 | } & { 11 | [$calls]: Set 12 | } 13 | 14 | function doSelectorsMatch(expected: any[], actual: any[]) { 15 | if (expected.length !== actual.length) { 16 | // tslint:disable-next-line no-console 17 | console.error("Memo selectors array's length is not supposed to change between calls.") 18 | return false 19 | } 20 | 21 | for (let index = 0; index < expected.length; index++) { 22 | if (actual[index] !== expected[index]) { 23 | return false 24 | } 25 | } 26 | return true 27 | } 28 | 29 | function createMemoizerFunction( 30 | callerID: string, 31 | memoized: Map, 32 | getCalls: () => Set 33 | ) { 34 | return function memo(value: T, selectors: any[]): T { 35 | const calls = getCalls() 36 | const prevMemoItem = memoized.get(callerID) 37 | 38 | if (!prevMemoItem && !selectors) { 39 | throw Error("No memo selectors passed. Pass selectors as 2nd argument.") 40 | } 41 | if (!prevMemoItem && !Array.isArray(selectors)) { 42 | throw Error("Memo selectors must be an array.") 43 | } 44 | if (!inProduction && calls.has(callerID)) { 45 | throw Error( 46 | `Inline memoizer memo.${callerID}() called twice during one render. ` + 47 | `This is usually a mistake, probably caused by copy & paste.` 48 | ) 49 | } 50 | 51 | const needToUpdate = !prevMemoItem || !doSelectorsMatch(prevMemoItem[0], selectors) 52 | 53 | if (needToUpdate) { 54 | memoized.set(callerID, [selectors, value]) 55 | } 56 | if (!inProduction) { 57 | calls.add(callerID) 58 | } 59 | 60 | return prevMemoItem ? prevMemoItem[1] : value 61 | } 62 | } 63 | 64 | function createMemoObjectWithKeys( 65 | identifiers: Key[], 66 | memoized: Map 67 | ): MapToMemoizers { 68 | const memo = {} as MapToMemoizers 69 | const getCalls = () => memo[$calls] 70 | 71 | for (const identifier of identifiers) { 72 | memo[identifier] = createMemoizerFunction(identifier, memoized, getCalls) as any 73 | } 74 | 75 | return memo 76 | } 77 | 78 | function createMemoProxyObject( 79 | memoized: Map 80 | ): MapToMemoizers { 81 | if (typeof Proxy === "undefined") { 82 | throw Error( 83 | "The JavaScript runtime does not support ES2015 Proxy objects.\n" + 84 | "Please call the hook with explicit property keys, like this:\n" + 85 | " useInlineMemo(\"key1\", \"key2\")" 86 | ) 87 | } 88 | 89 | const memo = {} as MapToMemoizers 90 | const getCalls = () => memo[$calls] 91 | 92 | return new Proxy(memo, { 93 | get(target, property) { 94 | const identifier = property as Key 95 | const initializedProp = target[identifier] 96 | 97 | if (initializedProp) { 98 | return initializedProp 99 | } else { 100 | const memoizer = createMemoizerFunction(identifier, memoized, getCalls) 101 | target[identifier] = memoizer as any 102 | return memoizer 103 | } 104 | } 105 | }) 106 | } 107 | 108 | export default function useInlineMemo(): MapToMemoizers 109 | export default function useInlineMemo(...identifiers: Key[]): MapToMemoizers 110 | export default function useInlineMemo(...identifiers: Key[]): MapToMemoizers { 111 | const memoized = React.useMemo(() => new Map(), []) 112 | 113 | const memoObject = React.useMemo(() => { 114 | if (identifiers.length > 0) { 115 | return createMemoObjectWithKeys(identifiers, memoized) 116 | } else { 117 | return createMemoProxyObject(memoized) 118 | } 119 | }, []) 120 | 121 | // Re-init the calls every time useInlineMemo() is called 122 | memoObject[$calls] = new Set() 123 | 124 | return memoObject 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

⚛︎ use-inline-memo

2 | 3 |

4 | React hook for memoizing values and callbacks anywhere in a component. 5 |

6 | 7 |

8 | Build status 9 | npm version 10 |

11 | 12 |
13 | 14 | Like other hooks, you can call [`React.useMemo()`](https://reactjs.org/docs/hooks-reference.html#usememo) and [`React.useCallback()`](https://reactjs.org/docs/hooks-reference.html#usecallback) only at the top of your component function and not use them conditionally. 15 | 16 | Inline memos let us memoize anywhere without the restrictions that apply to the usage of hooks! 17 | 18 | ```jsx 19 | import { Button, TextField } from "@material-ui/core" 20 | import React from "react" 21 | import useInlineMemo from "use-inline-memo" 22 | 23 | function NameForm(props) { 24 | const memo = useInlineMemo() 25 | const [newName, setNewName] = React.useState(props.prevName) 26 | 27 | // Conditional return prevents calling any hook after this line 28 | if (props.disabled) { 29 | return
(Disabled)
30 | } 31 | 32 | return ( 33 | 34 | setNewName(event.target.value), [])} 37 | value={newName} 38 | /> 39 | 45 | 46 | ) 47 | } 48 | ``` 49 | 50 | ## Installation 51 | 52 | ``` 53 | npm install use-inline-memo 54 | ``` 55 | 56 | ## Usage 57 | 58 | Everytime you want to memoize a value, call `memo.X()` with an identifier `X` of your choice. This identifier will be used to map the current call to values memoized in previous component renderings. 59 | 60 | Calling `useInlineMemo()` without arguments should work in all ES2015+ runtimes as it requires the ES2015 `Proxy`. If you need to support older browsers like the Internet Explorer, you will have to state the memoization keys up-front: 61 | 62 | ```jsx 63 | function NameForm(props) { 64 | // Explicit keys to make it work in IE11 65 | const memo = useInlineMemo("nameChange", "submitClick", "submitStyle") 66 | const [newName, setNewName] = React.useState(props.prevName) 67 | 68 | return ( 69 | 70 | setNewName(event.target.value), [])} 73 | value={newName} 74 | /> 75 | 81 | 82 | ) 83 | } 84 | ``` 85 | 86 | When not run in production, there is also a check in place to prevent you from accidentally using the same identifier for two different values. Calling `memo.X()` with the same `X` twice during one rendering will lead to an error. 87 | 88 | ## Use cases 89 | 90 | ### Event listeners 91 | 92 | Inline memoizers are perfect to define simple one-line callbacks in-place in the JSX without sacrificing performance. 93 | 94 | ```jsx 95 | // Before 96 | function Component() { 97 | const [value, setValue] = React.useState("") 98 | const onUserInput = React.useCallback( 99 | (event: React.SyntheticEvent) => setValue(event.target.value), 100 | [] 101 | ) 102 | return ( 103 | 104 | ) 105 | } 106 | ``` 107 | 108 | ```jsx 109 | // After 110 | function Component() { 111 | const memo = useInlineMemo() 112 | const [value, setValue] = React.useState("") 113 | return ( 114 | setValue(event.target.value), [])} 116 | value={value} 117 | /> 118 | ) 119 | } 120 | ``` 121 | 122 | ### `style` props & other objects 123 | 124 | Using inline style props is oftentimes an express ticket to unnecessary re-renderings as you will create a new style object on each rendering, even though its content will in many cases never change. 125 | 126 | ```jsx 127 | // Before 128 | function Component() { 129 | return ( 130 | 133 | ) 134 | } 135 | ``` 136 | 137 | ```jsx 138 | // After 139 | function Component() { 140 | const memo = useInlineMemo() 141 | return ( 142 | 145 | ) 146 | } 147 | ``` 148 | 149 | You don't need to memoize every style object of every single DOM element, though. Use it whenever you pass an object to a complex component which is expensive to re-render. 150 | 151 | For more background information check out [FAQs: Why memoize objects?](#faqs). 152 | 153 | ## API 154 | 155 | #### `useInlineMemo(): MemoFunction` 156 | 157 | Call it once in a component to obtain the `memo` object carrying the memoization functions. 158 | 159 | #### `useInlineMemo(...keys: string[]): MemoFunction` 160 | 161 | State the memoization keys explicitly if you need to support Internet Explorer where `Proxy` is not available. 162 | 163 | #### `memo[id: string](value: T, deps: any[]): T` 164 | 165 | Works the same as a call to `React.useMemo()` or `React.useCallback()`, only without the wrapping callback function. That wrapping function is useful to run expensive instantiations only if we actually refresh the value, but for our use case this is rather unlikely, so we provide you with a more convenient API instead. 166 | 167 | `id` is used to map different memoization calls between component renderings. 168 | 169 | ## FAQs 170 | 171 |
172 | How does that work? 173 | 174 | The reason why React hooks cannot be called arbitrarily is that React needs to match the current hook call to previous calls. The only way it can match them is by assuming that the same hooks will always be called in the same order. 175 | 176 | So what we do here is to provide a hook `useInlineMemo()` that creates a `Map` to match `memo.X()` calls to the memoized value and the deps array. We can match calls to `memo.X()` between different re-renderings by using `X` as an identifier. 177 |
178 | 179 |
180 | Why is memoization so important? 181 | 182 | To ensure good performance you want to re-render as few components as possible if some application state changes. In React we use `React.memo()` for that which judges by comparing the current component props to the props of the previous rendering. 183 | 184 | Without memoization we will very often create the same objects, callbacks, ... with the same content over and over again for each rendering, but as they are new instances every time, they will not be recognized as the same values and cause unnecessary re-renderings (see "Why memoize objects?" below). 185 |
186 | 187 |
188 | Why memoize objects? 189 | 190 | When `React.memo()` determines whether a component needs to be re-rendered, it tests if the property values are equivalent to the property values of the last rendering. It does though by comparing them for equality by reference, as anything else would take too much time. 191 | 192 | Now if the parent component creates a new object and passes it to the component as a property, React will only check if the object is exactly the same object instance as for the last rendering (`newObject === prevObject`). Even if the object has exactly the same properties as before, with the exact same values, it will nevertheless be a new object that just happens to have the same content. 193 | 194 | Without memoization `React.memo()` will always re-render the component as we keep passing new object instances – the equality comparison of the old and the new property value will never be true. Memoization makes sure to re-use the actual last object instance, thus skipping re-rendering. 195 |
196 | 197 | ## License 198 | 199 | MIT 200 | --------------------------------------------------------------------------------