-
-
Save KurtGokhan/9aafd8e83c9bc6a2946fe2dc7f2c1d19 to your computer and use it in GitHub Desktop.
/** | |
* A combined ref implementation using the callback ref cleanups feature. | |
* Note that this won't work yet as callback ref cleanups feature isn't released yet and thought to be released in React 19 | |
*/ | |
import { ForwardedRef, useCallback } from 'react'; | |
type OptionalRef<T> = ForwardedRef<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>[]) { | |
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 sorry for the confusion. This was something I created in response to a comment in React repo about callback ref cleanups. Callback ref cleanups are coming to React 19 and new
will be valid then.
As for the old
, I think it is one of the best merged ref implementations out on the internet. It can't be perfect because of limitations. But when callback ref cleanups releases, the new implementation will be almost perfect I think.
@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 get null
(so you know, if refs changed, your callback was called with null
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 multiple ref
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?
@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.
WARNING
For anyone landing here from Google: I think
new
is wrong since ref callbacks do not have cleanup functions, i.e. you can't return a function from a callback ref.Latest React even warns about it.
old
has a slightly correct-er behavior (still not perfect) and even tries to mimic the exact behavior of React where only the changing refs are called. Note there are some bugs in theold
implementation though. E.g. on line 28 I think it should saypreviousRefs.current[index]
. Also it's not taking into account that the ref callback will be called withnull
on detach, so it will be set tonull
twice.