🚀 Explore 100+ powerful React Hooks! Visit www.reactuse.com for complete documentation and MCP support, or install via npm install @reactuse/core
to supercharge your React development with our rich Hook collection!
Preface: When Multiple Refs Start Fighting
Have you ever encountered this awkward scenario: you want to add hover detection, focus management, and scroll monitoring to a button, only to discover that React only allows one ref per element?
function MyButton() { const hoverRef = useRef(null) const focusRef = useRef(null) const scrollRef = useRef(null) // 😰 This doesn't work! An element can only have one ref return ( <button ref={hoverRef} // ❌ Later refs will override earlier ones ref={focusRef} // ❌ This overrides hoverRef ref={scrollRef} // ❌ This overrides focusRef > Click me </button> ) }
Or when encapsulating components, you want to expose the internal DOM reference to parent components while also using your own refs for various operations:
// 😭 Dilemma: either internal operations or external access const ForwardButton = forwardRef((props, ref) => { const internalRef = useRef(null) // Needed internally for animations // Which ref to use? return <button ref={ref || internalRef}>Button</button> })
Congratulations, you've encountered the most common "ref conflict" problem in React component composition.
Ref Conflicts: The Silent Killer of Component Encapsulation
In modern React development, component encapsulation is becoming increasingly sophisticated. A seemingly simple button component might need:
- forwardRef support: Allow parent components to access the DOM
- Internal state management: Hover, focus, active state detection
- Animation control: Enter/exit animations, loading states
- Accessibility: Keyboard navigation, screen reader support
- Performance optimization: Scroll monitoring, size change detection
Each feature might require independent refs to manipulate the DOM, but React's ref mechanism is naturally "one-to-one". It's like several people wanting the same door key at once—nobody can get in.
Traditional Solutions: Each with Their Own Pain Points
Solution 1: Manual Callback Merging
function ProblematicButton() { const hoverRef = useRef(null) const focusRef = useRef(null) const animationRef = useRef(null) // 😰 Manually set each ref in the callback const refCallback = useCallback((node) => { // Must manually remember all refs that need to be set hoverRef.current = node focusRef.current = node animationRef.current = node // What if some ref is a function? Need additional checks... // if (typeof someCallbackRef === 'function') { // someCallbackRef(node) // } }, []) const isHovered = useHover(hoverRef) return <button ref={refCallback}>Button</button> }
Core Problems:
- Maintenance nightmare: Every time you add a new ref, you must remember to add it manually in the callback
- Type inconsistency: Cannot elegantly handle mixed function refs and object refs
- Easy to miss: Forgetting to add a ref in the callback leads to broken functionality
- Code repetition: Every component needing multiple refs requires similar boilerplate code
Solution 2: useImperativeHandle "Workaround"
// Scenario: Want to support both forwardRef and internal ref usage const ProblematicInput = forwardRef((props, externalRef) => { const internalRef = useRef(null) const validationRef = useRef(null) // For validation logic const autoCompleteRef = useRef(null) // For autocomplete functionality // 😭 Can only expose internal ref externally, but what about other functional refs? useImperativeHandle(externalRef, () => internalRef.current, []) // 🤔 Now the problem: how do validationRef and autoCompleteRef bind to the same DOM? // Can only choose one... return <input ref={internalRef} /> // Other functional refs can't be used! }) // Confusion when using function App() { const inputRef = useRef(null) return ( <ProblematicInput ref={inputRef} // Can only access internalRef // validationRef and autoCompleteRef functionality is lost /> ) }
Core Problems:
- Lost functionality: Can only expose one ref, other internal functional refs can't work simultaneously
- Semantic confusion: useImperativeHandle is for exposing methods, not solving multi-ref problems
- Poor extensibility: When more internal functionality is needed, can't elegantly add new refs
- Unclear responsibilities: Confuses "external interface exposure" with "internal function integration"
Solution 3: State-Driven "Ref Synchronization"
function ConfusingRefSync() { const [currentElement, setCurrentElement] = useState(null) const hoverRef = useRef(null) const focusRef = useRef(null) const measureRef = useRef(null) // 😵 Manually sync all refs whenever DOM element changes useEffect(() => { hoverRef.current = currentElement focusRef.current = currentElement measureRef.current = currentElement }, [currentElement]) const isHovered = useHover(hoverRef) const { width, height } = useMeasure(measureRef) // 🤨 Must remember to update state in callback const refCallback = useCallback((node) => { setCurrentElement(node) }, []) return ( <div ref={refCallback}> {isHovered ? `Hovering ${width}x${height}` : 'Not hovering'} </div> ) }
Core Problems:
- Timing issues: useEffect is asynchronous, may cause some hooks to not get DOM elements on first render
- Performance overhead: Every DOM change triggers state updates, which trigger effects, potentially causing extra renders
- Complexity explosion: As ref count increases, synchronization logic becomes increasingly complex
- Race conditions: In rapid switching scenarios, refs might point to wrong DOM elements
Real-World Pain Experience
// 😱 In real projects, you might see code like this... function RealWorldNightmare({ forwardedRef, enableTracking, enableAnimation }) { const baseRef = useRef(null) const trackingRef = useRef(null) const animationRef = useRef(null) const [element, setElement] = useState(null) // Complex conditional logic const refCallback = useCallback((node) => { setElement(node) baseRef.current = node if (enableTracking && trackingRef.current !== node) { trackingRef.current = node } if (enableAnimation) { animationRef.current = node } // Also need to handle externally passed ref if (forwardedRef) { if (typeof forwardedRef === 'function') { forwardedRef(node) } else { forwardedRef.current = node } } }, [forwardedRef, enableTracking, enableAnimation]) // Synchronization logic useEffect(() => { if (enableTracking) { trackingRef.current = element } else { trackingRef.current = null } }, [element, enableTracking]) // ... More complex synchronization logic return <div ref={refCallback}>Nightmare-level component</div> }
The problems with this code are obvious:
- Poor readability: New team members need a long time to understand this logic
- Difficult maintenance: Any modification might cause chain reactions
- Error-prone: Complex conditional logic makes bugs likely in certain scenarios
- Performance issues: Lots of effects and state updates
Elegant Solution: useMergedRefs
Let's look at a truly elegant solution:
import { useMemo } from 'react' import type { Ref } from 'react' type PossibleRef<T> = Ref<T> | undefined export function assignRef<T>(ref: PossibleRef<T>, value: T) { if (ref == null) return if (typeof ref === 'function') { ref(value) return } try { (ref as React.MutableRefObject<T>).current = value } catch (error) { throw new Error(`Cannot assign value '${value}' to ref '${ref}'`) } } export function mergeRefs<T>(...refs: PossibleRef<T>[]) { return (node: T | null) => { refs.forEach(ref => { assignRef(ref, node) }) } } export function useMergedRefs<T>(...refs: PossibleRef<T>[]) { // eslint-disable-next-line react-hooks/exhaustive-deps return useMemo(() => mergeRefs(...refs), refs) }
Design Philosophy: Unified Yet Flexible
The core idea of this design is "unified interface, distributed responsibility":
1. Unified Assignment Logic
The assignRef
function handles both forms of refs in React:
- Function form:
(node) => { /* do something */ }
- Object form:
{ current: null }
// Supports function refs const callbackRef = (node) => { console.log('DOM element:', node) } // Supports object refs const objectRef = useRef(null) // Unified handling assignRef(callbackRef, element) // Calls function assignRef(objectRef, element) // Sets .current
2. Smart Merging Strategy
mergeRefs
creates a new ref callback that sequentially calls all passed refs:
const mergedRef = mergeRefs(ref1, ref2, ref3) // Equivalent to: const mergedRef = (node) => { assignRef(ref1, node) assignRef(ref2, node) assignRef(ref3, node) }
3. Performance-Optimized Hook
useMergedRefs
uses useMemo
to avoid unnecessary recreation:
// ✅ Only recreates when refs array changes const mergedRef = useMergedRefs(ref1, ref2, ref3) // ❌ Creates new function every render const mergedRef = (node) => { assignRef(ref1, node) assignRef(ref2, node) assignRef(ref3, node) }
Real-World Applications: From Simple to Complex
Scenario 1: Basic Multi-Functional Button
import { useRef } from 'react' import { useMergedRefs, useHover, useFocus } from '@reactuse/core' function SmartButton({ children, ...props }) { const hoverRef = useRef(null) const focusRef = useRef(null) const animationRef = useRef(null) const isHovered = useHover(hoverRef) const isFocused = useFocus(focusRef) // ✨ Magic moment: three refs become one const mergedRef = useMergedRefs(hoverRef, focusRef, animationRef) const handleClick = () => { // Use animationRef to control click animation if (animationRef.current) { animationRef.current.style.transform = 'scale(0.95)' setTimeout(() => { animationRef.current.style.transform = 'scale(1)' }, 150) } } return ( <button ref={mergedRef} onClick={handleClick} style={{ backgroundColor: isHovered ? '#0066cc' : '#0080ff', outline: isFocused ? '2px solid #ff6600' : 'none', transition: 'all 0.2s ease', border: 'none', padding: '12px 24px', borderRadius: '6px', color: 'white', cursor: 'pointer' }} {...props} > {children} </button> ) }
Scenario 2: Complex Component with forwardRef Support
import { forwardRef, useRef, useEffect } from 'react' import { useMergedRefs } from '@reactuse/core' const AdvancedInput = forwardRef(({ onValueChange, ...props }, externalRef) => { const internalRef = useRef(null) const validationRef = useRef(null) const autoCompleteRef = useRef(null) // 🎯 Key: merge external ref with multiple internal refs const mergedRef = useMergedRefs( externalRef, // Parent-passed ref internalRef, // Internal state management validationRef, // Validation logic autoCompleteRef // Autocomplete functionality ) // Internal feature: real-time validation useEffect(() => { const handleInput = (e) => { const value = e.target.value const isValid = value.length >= 3 if (validationRef.current) { validationRef.current.style.borderColor = isValid ? 'green' : 'red' } onValueChange?.(value, isValid) } const element = internalRef.current if (element) { element.addEventListener('input', handleInput) return () => element.removeEventListener('input', handleInput) } }, [onValueChange]) // Internal feature: autocomplete useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'Tab' && autoCompleteRef.current) { // Autocomplete logic console.log('Trigger autocomplete') } } const element = autoCompleteRef.current if (element) { element.addEventListener('keydown', handleKeyDown) return () => element.removeEventListener('keydown', handleKeyDown) } }, []) return ( <input ref={mergedRef} {...props} style={{ padding: '8px 12px', border: '2px solid #ddd', borderRadius: '4px', fontSize: '16px', transition: 'border-color 0.2s ease', ...props.style }} /> ) }) // Usage example function App() { const inputRef = useRef(null) const focusInput = () => { inputRef.current?.focus() } return ( <div> <AdvancedInput ref={inputRef} // ✅ External access works normally placeholder="Enter at least 3 characters..." onValueChange={(value, isValid) => { console.log('Value changed:', value, 'Valid:', isValid) }} /> <button onClick={focusInput}>Focus Input</button> </div> ) }
Scenario 3: Advanced Component Composition
import { useRef, forwardRef } from 'react' import { useMergedRefs, useResizeObserver, useIntersectionObserver } from '@reactuse/core' const ObservableCard = forwardRef(({ children, onResize, onVisibilityChange }, ref) => { const resizeRef = useRef(null) const intersectionRef = useRef(null) const cardRef = useRef(null) // 📊 Size monitoring useResizeObserver(resizeRef, (entries) => { const { width, height } = entries[0].contentRect onResize?.({ width, height }) }) // 👁️ Visibility monitoring useIntersectionObserver(intersectionRef, (entries) => { const isVisible = entries[0].isIntersecting onVisibilityChange?.(isVisible) }) // 🔗 Perfect fusion: external ref + multiple observer refs + internal operation ref const mergedRef = useMergedRefs(ref, resizeRef, intersectionRef, cardRef) return ( <div ref={mergedRef} style={{ padding: '20px', margin: '10px', border: '1px solid #e0e0e0', borderRadius: '8px', backgroundColor: 'white', boxShadow: '0 2px 4px rgba(0,0,0,0.1)', minHeight: '200px' }} > {children} </div> ) }) // Usage example function Dashboard() { const cardRef = useRef(null) return ( <div style={{ height: '200vh', padding: '20px' }}> <ObservableCard ref={cardRef} onResize={({ width, height }) => { console.log(`Card size changed: ${width}x${height}`) }} onVisibilityChange={(isVisible) => { console.log(`Card ${isVisible ? 'entered' : 'left'} viewport`) }} > <h3>Smart Card</h3> <p>This card monitors size changes and visibility changes</p> <button onClick={() => { // External access to DOM still works cardRef.current?.scrollIntoView({ behavior: 'smooth' }) }} > Scroll to this card </button> </ObservableCard> </div> ) }
Advanced Features: Error Handling and Edge Cases
Null Safety
// ✅ Automatically filters null values, won't error const mergedRef = useMergedRefs( someRef, // might be null undefined, // might be undefined anotherRef // normal ref )
Dynamic Ref Arrays
function DynamicRefComponent({ refs = [] }) { const internalRef = useRef(null) // 🎨 Dynamically merge any number of refs const mergedRef = useMergedRefs(internalRef, ...refs) return <div ref={mergedRef}>Dynamic ref merging</div> }
Conditional Ref Merging
function ConditionalRefComponent({ enableTracking }) { const baseRef = useRef(null) const trackingRef = useRef(null) // 🎯 Conditionally include certain refs const mergedRef = useMergedRefs( baseRef, enableTracking ? trackingRef : null ) return <div ref={mergedRef}>Conditional ref</div> }
Performance Considerations: Avoiding Unnecessary Re-renders
Importance of useMemo
// ❌ Creates new function every render, may cause child re-renders function BadExample() { const ref1 = useRef(null) const ref2 = useRef(null) return <MyComponent ref={mergeRefs(ref1, ref2)} /> } // ✅ Uses useMergedRefs, only recreates when refs change function GoodExample() { const ref1 = useRef(null) const ref2 = useRef(null) const mergedRef = useMergedRefs(ref1, ref2) return <MyComponent ref={mergedRef} /> }
Dependency Array Optimization
function OptimizedComponent({ externalRef }) { const internalRef = useRef(null) // 🚀 useMemo automatically handles dependency array, no manual optimization needed const mergedRef = useMergedRefs(externalRef, internalRef) return <div ref={mergedRef}>Optimized component</div> }
Perfect Integration with Other Hooks
Combined with useImperativeHandle
const CustomInput = forwardRef((props, ref) => { const inputRef = useRef(null) const internalRef = useRef(null) const mergedRef = useMergedRefs(inputRef, internalRef) useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { if (inputRef.current) { inputRef.current.value = '' } }, getElement: () => inputRef.current }), []) return <input ref={mergedRef} {...props} /> })
Combined with Custom Hooks
function useSmartButton() { const hoverRef = useRef(null) const clickRef = useRef(null) const animationRef = useRef(null) const isHovered = useHover(hoverRef) const clickCount = useClickCounter(clickRef) const mergedRef = useMergedRefs(hoverRef, clickRef, animationRef) return { ref: mergedRef, isHovered, clickCount, animateClick: () => { // Animation logic } } }
Debugging Tips: Tracking Ref State
Adding Debug Information
function DebugMergedRefs(...refs) { const mergedRef = useMergedRefs(...refs) // Add debugging in development environment const debugRef = useCallback((node) => { console.log('MergedRef assignment:', node) console.log('Active ref count:', refs.filter(Boolean).length) return mergedRef(node) }, [mergedRef]) return process.env.NODE_ENV === 'development' ? debugRef : mergedRef }
Monitoring Ref State
function useRefMonitor(refs) { useEffect(() => { console.log('Ref state change:', refs.map(ref => ({ type: typeof ref, current: ref?.current || 'N/A' }))) }, refs) }
Conclusion: The Art of Ref Management
useMergedRefs
is not just a utility function—it represents a component design philosophy: maintaining functional independence while achieving perfect collaboration.
Like an excellent conductor, it allows each "musician" (ref) to showcase their specialty while ensuring the entire "orchestra" (component) works harmoniously. Whether it's a simple button component or a complex data visualization component, useMergedRefs
helps you elegantly solve ref conflict problems.
Core Value Summary
- Solves fundamental problems: Completely resolves multi-ref conflicts rather than working around them
- Keeps code clean: Avoids complex manual synchronization logic
- Improves maintainability: Each ref has clear responsibilities, easy to understand and modify
- Enhances reusability: Components can be safely composed and extended
- Optimizes performance: Smart memoization avoids unnecessary re-renders
Final Advice
Remember, good tools should make complex things simple, not make simple things complex. useMergedRefs
is exactly such a tool—it lets you focus on business logic without worrying about technical details.
Ready-to-Use Solution
If you don't want to implement it yourself, you can directly use the ready-made solution from the ReactUse library:
npm install @reactuse/core
import { useMergedRefs } from '@reactuse/core' function MyComponent() { const ref1 = useRef(null) const ref2 = useRef(null) const mergedRef = useMergedRefs(ref1, ref2) return <div ref={mergedRef}>Perfect fusion</div> }
Next time you encounter ref conflicts in component composition, remember this elegant solution. Let each ref fulfill its value and make your components more robust and user-friendly.
Top comments (0)