├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.ts ├── test └── index.test.tsx └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 2 4 | insert_final_newline = true 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es6": true 6 | }, 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 2018, 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "comma-dangle": ["error", "always-multiline"], 18 | "no-var": "error", 19 | "quotes": ["error", "single", { "avoidEscape": true }], 20 | "semi": ["error", "never"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Test 26 | run: yarn test --ci --coverage --maxWorkers=2 27 | 28 | - name: Build 29 | run: yarn build 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Torvin 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 | # react-ref-composer 2 | 3 | This package provides a simple of combining several [React refs](https://reactjs.org/docs/refs-and-the-dom.html) into a single ref that can passed to a single component or DOM node. See [React issue #13029](https://github.com/facebook/react/issues/13029) for more details. 4 | 5 | This is most useful when you want to keep a ref to a node inside your component and also forward it outside using [`React.forwardRef`](https://reactjs.org/docs/forwarding-refs.html) or pass to a library such as [`react-beautiful-dnd`](https://github.com/atlassian/react-beautiful-dnd). 6 | 7 | Both React hooks and class components are supported. 8 | 9 | ## Hooks 10 | 11 | `useRefComposer` is a hook for composing refs. Usage example: 12 | 13 | ```jsx 14 | import { useRefComposer } from 'react-ref-composer' 15 | 16 | export const MyComponent = React.forwardRef((props, outerRef) => { 17 | const innerRef = useRef() 18 | const composeRefs = useRefComposer() 19 | 20 | return
test
21 | }) 22 | ``` 23 | 24 | Here `composeRef` is a function that you can call with any number of refs. Both object refs and callback refs are supported. The function returns a single "combined" callback ref. 25 | 26 | Make sure to always call `composeRef` with the same number of arguments. In cases when conditional passing of a ref is required you can pass any falsy (`undefined`, `null`, `0`, `""` or `false`) value instead to temporary "turn off" the ref, e.g.: 27 | ```jsx 28 | ... ref={composeRefs(ref1, a && b && ref2)} 29 | ``` 30 | 31 | ## Class components 32 | 33 | Class components work very similarly, just use `createRefComposer` instead: 34 | 35 | ```js 36 | import { createRefComposer } from 'react-ref-composer' 37 | 38 | export class MyComponent { 39 | constructor(props) { 40 | super(props) 41 | this.composeRefs = createRefComposer() 42 | } 43 | 44 | render() { 45 | return
test
46 | } 47 | } 48 | ``` 49 | 50 | Same rules for `composeRef` as above apply. 51 | 52 | ## Why another library? 53 | 54 | Why create another library? The main problem with existing libraries, including [`compose-react-refs`](https://github.com/seznam/compose-react-refs), is that none of them handle changing only one of the passed refs correctly. Case in point: 55 | 56 | ```jsx 57 | function MyComponent(){ 58 | const composeRefs = useRefComposer() 59 | const ref1 = useCallback(div => console.log('ref1', div), []) 60 | const ref2 = useCallback(div => console.log('ref2', div), []) 61 | const ref3 = useCallback(div => console.log('ref3', div), []) 62 | const [flag, setFlag] = useState(true) 63 | 64 | function onSwitch() { 65 | console.log('switching') 66 | setFlag(f => !f) 67 | } 68 | 69 | return
70 | 71 |
72 | } 73 | ``` 74 | 75 | This is what the expected output looks like when the user clicks the button: 76 | ```jsx 77 | ref1
78 | ref2
79 | switching 80 | ref2 null 81 | ref3
82 | ``` 83 | 84 | So the old ref resets to `null` and the new ref is set to the DOM node as expected. 85 | 86 | However with `compose-react-refs` and other similar libraries this happens: 87 | 88 | ```jsx 89 | ref1
90 | ref2
91 | switching 92 | ref1 null 93 | ref2 null 94 | ref1
95 | ref3
96 | ``` 97 | 98 | Essentially `ref1` goes through reset/set cycle. This will trick the consumer of that ref into thinking that the component got unmounted and remounted again, which can be harmful in some cases (e.g. during drag-and-drop operation) and cause undesired behaviour. 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.1", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test", 17 | "prepare": "tsdx build", 18 | "push": "npx np --no-release-draft --no-2fa" 19 | }, 20 | "peerDependencies": { 21 | "react": ">= 16.8.0" 22 | }, 23 | "name": "react-ref-composer", 24 | "author": "Torvin", 25 | "module": "dist/react-ref-composer.esm.js", 26 | "devDependencies": { 27 | "@testing-library/react": "^11.2.6", 28 | "@types/react": "^17.0.3", 29 | "react": "^17.0.2", 30 | "react-dom": "^17.0.2", 31 | "tsdx": "^0.14.1", 32 | "tslib": "^2.2.0", 33 | "typescript": "^4.2.4" 34 | }, 35 | "dependencies": {}, 36 | "repository": "github:Torvin/react-ref-composer" 37 | } 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react' 2 | 3 | type Ref = React.Ref | undefined | false | 0 | '' 4 | 5 | export function useRefComposer() { 6 | const ref = useRef(null) 7 | const prevRefs = useRef[]>() 8 | 9 | const cb = useCallback((val: T | null) => { 10 | ref.current = val 11 | prevRefs.current!.forEach(ref => updateRef(ref, val)) 12 | }, []) 13 | 14 | return useCallback((...refs: Ref[]) => { 15 | if (prevRefs.current) { 16 | if (prevRefs.current.length !== refs.length) { 17 | throw new Error(`args length mismatch: old length: ${prevRefs.current.length}, new length: ${refs.length}`) 18 | } 19 | 20 | for (let i = 0; i < refs.length; i++) { 21 | const oldRef = prevRefs.current[i] 22 | if (oldRef !== refs[i]) { 23 | updateRef(oldRef, null) 24 | } 25 | } 26 | 27 | for (let i = 0; i < refs.length; i++) { 28 | const oldRef = prevRefs.current[i] 29 | const newRef = refs[i] 30 | if (oldRef !== newRef) { 31 | prevRefs.current[i] = newRef 32 | updateRef(newRef, ref.current) 33 | } 34 | } 35 | } 36 | 37 | prevRefs.current = refs 38 | return cb 39 | }, [cb]) 40 | } 41 | 42 | export function createRefComposer() { 43 | let ref: T | null = null 44 | let prevRefs: Ref[] 45 | 46 | const cb = (val: T | null) => { 47 | ref = val 48 | prevRefs.forEach(ref => updateRef(ref, val)) 49 | } 50 | 51 | return (...refs: Ref[]) => { 52 | if (prevRefs) { 53 | if (prevRefs.length !== refs.length) { 54 | throw new Error(`args length mismatch: old length: ${prevRefs.length}, new length: ${refs.length}`) 55 | } 56 | 57 | for (let i = 0; i < refs.length; i++) { 58 | const oldRef = prevRefs[i] 59 | if (oldRef !== refs[i]) { 60 | updateRef(oldRef, null) 61 | } 62 | } 63 | 64 | for (let i = 0; i < refs.length; i++) { 65 | const oldRef = prevRefs[i] 66 | const newRef = refs[i] 67 | if (oldRef !== newRef) { 68 | prevRefs[i] = newRef 69 | updateRef(newRef, ref) 70 | } 71 | } 72 | } 73 | 74 | prevRefs = refs 75 | return cb 76 | } 77 | } 78 | 79 | function updateRef(ref: Ref, value: T | null) { 80 | if (!ref) { return } 81 | 82 | if (typeof ref === 'function') { 83 | ref(value) 84 | } else if ('current' in ref) { 85 | (ref as React.MutableRefObject).current = value 86 | } else { 87 | throw new Error('First argument should be a React ref or a falsy value.') 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import React from 'react' 3 | import { useRefComposer } from '../src' 4 | 5 | it("doesn't call unchanged ref", () => { 6 | const ref1 = makeCountingRef() 7 | const ref2 = makeCountingRef() 8 | const ref3 = makeCountingRef() 9 | 10 | const res = render() 11 | res.rerender() 12 | const div = res.container.firstChild 13 | 14 | expect(ref1.getValues()).toEqual([div]) 15 | expect(ref2.getValues()).toEqual([div, null]) 16 | expect(ref3.getValues()).toEqual([div]) 17 | 18 | res.unmount() 19 | }) 20 | 21 | function Test({ ref1, ref2 }: { ref1: React.Ref, ref2: React.Ref }) { 22 | const ref = useRefComposer() 23 | return
24 | } 25 | 26 | function makeCountingRef() { 27 | const values: Array = [] 28 | const ref = (arg: HTMLDivElement | null) => { 29 | values.push(arg) 30 | } 31 | 32 | return Object.assign(ref, { 33 | getValues() { 34 | return values 35 | }, 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // use Node's module resolution algorithm, instead of the legacy TS one 20 | "moduleResolution": "node", 21 | // transpile JSX to React.createElement 22 | "jsx": "react", 23 | // interop between ESM and CJS modules. Recommended by TS 24 | "esModuleInterop": true, 25 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 26 | "skipLibCheck": true, 27 | // error out if import and file system have a casing mismatch. Recommended by TS 28 | "forceConsistentCasingInFileNames": true, 29 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 30 | "noEmit": true, 31 | } 32 | } 33 | --------------------------------------------------------------------------------