Skip to content

Commit 46a1bd9

Browse files
author
João Dias
committed
feat(useMergeRefs): add option to merge two or more refs
1 parent 5481249 commit 46a1bd9

File tree

2 files changed

+127
-25
lines changed

2 files changed

+127
-25
lines changed

docs/docs/hooks/use-merge-refs.mdx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ This hook enhances component flexibility and reusability, allowing developers to
1313

1414
```typescript
1515
function useMergeRefs<Generic = HTMLElement>(firstRef: SingleRef<Generic>, secondRef: SingleRef<Generic>): MergedRefCallback<Generic>;
16+
function useMergeRefs<Generic = HTMLElement>(refs: Array<SingleRef<Generic> | undefined>): MergedRefCallback<Generic>;
1617
```
1718

1819
### Usage
1920

2021
```tsx
2122
import { useMergeRefs } from '@feedzai/js-utilities/hooks';
2223

23-
// a div with multiple refs
24+
// a div with two refs
2425
function Example({ ref, ...props }) {
2526
const internalRef = React.useRef();
2627
const refs = useMergeRefs(internalRef, ref);
@@ -31,4 +32,17 @@ function Example({ ref, ...props }) {
3132
</div>
3233
);
3334
}
35+
36+
// a div with multiple refs
37+
function Example({ ref, ...props }) {
38+
const internalRef = React.useRef();
39+
const anotherRef = React.useRef();
40+
const refs = useMergeRefs([internalRef, ref, anotherRef]);
41+
42+
return (
43+
<div {...props} ref={refs}>
44+
A div with multiple refs.
45+
</div>
46+
);
47+
}
3448
```

src/hooks/use-merge-refs.ts

Lines changed: 112 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
/**
2-
* Please refer to the terms of the license agreement in the root of the project
2+
* The copyright of this file belongs to Feedzai. The file cannot be
3+
* reproduced in whole or in part, stored in a retrieval system, transmitted
4+
* in any form, or by any means electronic, mechanical, or otherwise, without
5+
* the prior permission of the owner. Please refer to the terms of the license
6+
* agreement.
37
*
4-
* (c) 2024 Feedzai
8+
* (c) 2023 Feedzai, Rights Reserved.
59
*/
6-
import { useMemo } from "react";
7-
import { isNil, isFunction, throwError } from "../functions";
10+
import { useMemo, useRef, useCallback } from "react";
11+
import { isFunction, isNil, throwError } from "src/functions";
812

9-
export type ReactRef<Generic> = React.RefCallback<Generic> | React.MutableRefObject<Generic>;
10-
export type SingleRef<Generic> = ReactRef<Generic> | null | undefined;
11-
export type MergedRefCallback<Generic> = (node: Generic | null) => void;
13+
type ReactRef<Generic> = React.RefCallback<Generic> | React.MutableRefObject<Generic>;
14+
type SingleRef<Generic> = ReactRef<Generic> | null | undefined;
15+
type MergedRefCallback<Generic> = (node: Generic | null) => void;
1216

1317
/**
1418
* Assigns values to each ref
@@ -26,7 +30,7 @@ export function assignRef<Generic = HTMLElement>(ref: SingleRef<Generic>, value:
2630
try {
2731
ref.current = value;
2832
} catch (error) {
29-
throwError("helpers", "useMergeRefs", "Cannot assign value to ref");
33+
throwError("@feedzai/js-utilities", "useMergeRefs", "Cannot assign value to ref");
3034
}
3135
}
3236

@@ -44,37 +48,121 @@ export function mergeRefs<Generic = HTMLElement>(
4448
}
4549

4650
/**
47-
* The useMergeRefs hook is designed to combine multiple React refs into a single callback ref.
51+
* Merges an array of refs into a single memoized callback ref or `null`.
52+
* Supports both cleanup functions returned by ref callbacks and proper cleanup on unmount.
4853
*
49-
* This is particularly useful when you need to apply multiple refs to a single element, such as when working with
50-
* both internal component logic and external ref forwarding. By merging refs, you can maintain the functionality of
51-
* each individual ref while avoiding conflicts or overrides that might occur when attempting to assign multiple
52-
* refs directly.
53-
*
54-
* This hook enhances component flexibility and reusability, allowing developers to easily integrate both
55-
* component-specific refs and externally provided refs in a clean, efficient manner.
54+
* @param refs - Array of refs to merge
55+
* @returns A merged ref callback or null if all refs are null/undefined
56+
*/
57+
export function useMergeArrayOfRefs<Generic = HTMLElement>(
58+
refs: Array<SingleRef<Generic> | undefined>
59+
): null | MergedRefCallback<Generic> {
60+
const cleanupRef = useRef<void | (() => void)>(undefined);
61+
62+
const refEffect = useCallback((instance: Generic | null) => {
63+
const cleanups = refs.map((ref) => {
64+
if (ref === null || ref === undefined) {
65+
return undefined;
66+
}
67+
68+
if (typeof ref === "function") {
69+
const refCallback = ref;
70+
71+
const refCleanup: void | (() => void) = refCallback(instance);
72+
73+
return typeof refCleanup === "function"
74+
? refCleanup
75+
: () => {
76+
refCallback(null);
77+
};
78+
}
79+
80+
(ref as React.MutableRefObject<Generic | null>).current = instance;
81+
return () => {
82+
(ref as React.MutableRefObject<Generic | null>).current = null;
83+
};
84+
});
85+
86+
return () => {
87+
cleanups.forEach((refCleanup) => refCleanup?.());
88+
};
89+
// eslint-disable-next-line react-hooks/exhaustive-deps
90+
}, refs);
91+
92+
return useMemo(() => {
93+
if (refs.every((ref) => isNil(ref))) {
94+
return null;
95+
}
96+
97+
return (value: Generic | null) => {
98+
if (cleanupRef.current) {
99+
cleanupRef.current();
100+
cleanupRef.current = undefined;
101+
}
102+
103+
if (value !== null) {
104+
cleanupRef.current = refEffect(value);
105+
}
106+
};
107+
// eslint-disable-next-line react-hooks/exhaustive-deps
108+
}, refs);
109+
}
110+
111+
// Overloaded function signatures for useMergeRefs
112+
export function useMergeRefs<Generic = HTMLElement>(
113+
refs: Array<SingleRef<Generic> | undefined>
114+
): null | MergedRefCallback<Generic>;
115+
export function useMergeRefs<Generic = HTMLElement>(
116+
firstRef: SingleRef<Generic>,
117+
secondRef: SingleRef<Generic>
118+
): MergedRefCallback<Generic>;
119+
120+
/**
121+
* Returns a function that receives the element and assigns the value to the given React refs.
122+
* Supports both the legacy two-parameter API and the new array API.
56123
*
57124
* @example
58125
* ```tsx
59-
* import { useMergeRefs } from '@feedzai/js-utilities/hooks';
60-
* ...
61-
* // a div with multiple refs
126+
* // Legacy two-parameter API (backwards compatible)
62127
* function Example({ ref, ...props }) {
63128
* const internalRef = React.useRef();
64129
* const refs = useMergeRefs(internalRef, ref);
65130
*
66131
* return (
67132
* <div {...props} ref={refs}>
133+
* A div with two refs.
134+
* </div>
135+
* );
136+
* }
137+
*
138+
* // New array API (supports any number of refs)
139+
* function Example({ ref, ...props }) {
140+
* const internalRef = React.useRef();
141+
* const anotherRef = React.useRef();
142+
* const refs = useMergeRefs([internalRef, ref, anotherRef]);
143+
*
144+
* return (
145+
* <div {...props} ref={refs}>
68146
* A div with multiple refs.
69147
* </div>
70148
* );
71149
* }
72150
* ```
73151
*/
74152
export function useMergeRefs<Generic = HTMLElement>(
75-
firstRef: SingleRef<Generic>,
76-
secondRef: SingleRef<Generic>
77-
): MergedRefCallback<Generic> {
78-
// eslint-disable-next-line react-hooks/exhaustive-deps
79-
return useMemo(() => mergeRefs(firstRef, secondRef), [firstRef, secondRef]);
153+
...args: [Array<SingleRef<Generic> | undefined>] | [SingleRef<Generic>, SingleRef<Generic>]
154+
): null | MergedRefCallback<Generic> {
155+
// Normalize arguments to always use array format to avoid conditional hooks
156+
const refsArray = useMemo(() => {
157+
if (Array.isArray(args[0])) {
158+
return args[0];
159+
}
160+
// Legacy two-parameter API
161+
const [firstRef, secondRef] = args as [SingleRef<Generic>, SingleRef<Generic>];
162+
163+
return [firstRef, secondRef];
164+
}, [args]);
165+
166+
// Always use the array implementation
167+
return useMergeArrayOfRefs(refsArray);
80168
}

0 commit comments

Comments
 (0)