Skip to content

Instantly share code, notes, and snippets.

@KurtGokhan
Last active March 13, 2024 10:03
Show Gist options
  • Save KurtGokhan/9aafd8e83c9bc6a2946fe2dc7f2c1d19 to your computer and use it in GitHub Desktop.
Save KurtGokhan/9aafd8e83c9bc6a2946fe2dc7f2c1d19 to your computer and use it in GitHub Desktop.
useCombinedRefs - Old and new
/**
* 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
Copy link

alvaro-cuesta commented Nov 2, 2023

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 the old implementation though. E.g. on line 28 I think it should say previousRefs.current[index]. Also it's not taking into account that the ref callback will be called with null on detach, so it will be set to null twice.

@KurtGokhan
Copy link
Author

@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.

@alvaro-cuesta
Copy link

alvaro-cuesta commented Nov 2, 2023

@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?

@KurtGokhan
Copy link
Author

KurtGokhan commented Nov 2, 2023

@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.

@DeniseMcClellan
Copy link

DeniseMcClellan commented Dec 5, 2023

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment