-
-
Save KurtGokhan/9aafd8e83c9bc6a2946fe2dc7f2c1d19 to your computer and use it in GitHub Desktop.
/** | |
* A combined ref implementation using the callback ref cleanups feature. | |
* This will work in React 19. | |
*/ | |
import { Ref, useCallback } from 'react'; | |
type OptionalRef<T> = Ref<T> | undefined; | |
type Cleanup = (() => void) | undefined | void; | |
function setRef<T>(ref: OptionalRef<T>, value: T): Cleanup { | |
if (typeof ref === 'function') { | |
const cleanup = ref(value); | |
if (typeof cleanup === 'function') { | |
return cleanup; | |
} | |
return () => ref(null); | |
} else if (ref) { | |
ref.current = value; | |
return () => (ref.current = null); | |
} | |
} | |
export function useCombinedRefs<T>(...refs: OptionalRef<T>[]) { | |
// biome-ignore lint/correctness/useExhaustiveDependencies: The hook already lists all dependencies | |
return useCallback((value: T | null) => { | |
const cleanups: Cleanup[] = []; | |
for (const ref of refs) { | |
const cleanup = setRef(ref, value); | |
cleanups.push(cleanup); | |
} | |
return () => { | |
for (const cleanup of cleanups) { | |
cleanup?.(); | |
} | |
}; | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, refs); | |
} |
/** | |
* A combined ref implementation that will work in React 18 | |
*/ | |
import { ForwardedRef, useCallback, useRef } from 'react'; | |
type OptionalRef<T> = ForwardedRef<T> | undefined; | |
function setRef<T>(ref: OptionalRef<T>, value: T) { | |
if (typeof ref === 'function') { | |
ref(value); | |
} else if (ref) { | |
ref.current = value; | |
} | |
} | |
export function useCombinedRefs<T>(...refs: OptionalRef<T>[]) { | |
const previousRefs = useRef<OptionalRef<T>[]>([]); | |
return useCallback((value: T | null) => { | |
let index = 0; | |
for (; index < refs.length; index++) { | |
const ref = refs[index]; | |
const prev = previousRefs.current[index]; | |
// eslint-disable-next-line eqeqeq | |
if (prev != ref) setRef(prev, null); | |
setRef(ref, value); | |
} | |
for (; index < previousRefs.current.length; index++) { | |
const prev = previousRefs.current[index]; | |
setRef(prev, null); | |
} | |
previousRefs.current = refs; | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, refs); | |
} |
@alvaro-cuesta I don't know any such implementation. Unfortunately, most implementations don't put so much thought on it.
Personally, I am fine if a ref is called multiple times with the same value or null
. Because your components must be resilient to multiple renders as well as multiple side effects, for the same reason React calls useEffect
twice in strict mode.
It will be mostly solved when Ref Cleanups are released though. Because then, React will keep store the previous ref instead of us having to keep it.
The 'use-combined-refs-new.ts' version anticipates the callback ref cleanup feature in React 19, which is yet to be released. The 'use-combined-refs-old.ts' version is tailored for React 18 and employs useRef for maintaining previous refs. Both are useful for combining multiple refs into a single ref.
@KurtGokhan thanks for your clarification! I didn't know this was coming to React 19, that's awesome.
I've been trying to do my own combined refs implementation and I'm reaching a similar conclusion. It's hard to know when the callback
null
is from an unmount or due to callback change.I was thinking maybe it could be hacked by storing some
refsHaveChangedRef
and checking that when you getnull
(so you know, if refs changed, your callback was called withnull
due to it, compared to the unmount case), but I'm worried it might desync if there are multiple calls to the hook without an actual render being committed. Also it breaks if you use the same combined ref across multipleref
props (but it might be possible to hack it too, by storing "has changed" by "previous instance" so it can be called once per possibly-attached ref).Do you know any implementation that tries this approach?