DEV Community

reactuse.com
reactuse.com

Posted on

useMergedRefs: The Essential Custom Hook for Component Composition

🚀 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>  ) } 
Enter fullscreen mode Exit fullscreen mode

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> }) 
Enter fullscreen mode Exit fullscreen mode

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> } 
Enter fullscreen mode Exit fullscreen mode

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 /> ) } 
Enter fullscreen mode Exit fullscreen mode

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>  ) } 
Enter fullscreen mode Exit fullscreen mode

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> } 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

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>  ) } 
Enter fullscreen mode Exit fullscreen mode

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>  ) } 
Enter fullscreen mode Exit fullscreen mode

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>  ) } 
Enter fullscreen mode Exit fullscreen mode

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 ) 
Enter fullscreen mode Exit fullscreen mode

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> } 
Enter fullscreen mode Exit fullscreen mode

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> } 
Enter fullscreen mode Exit fullscreen mode

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} /> } 
Enter fullscreen mode Exit fullscreen mode

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> } 
Enter fullscreen mode Exit fullscreen mode

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} /> }) 
Enter fullscreen mode Exit fullscreen mode

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 } } } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

Monitoring Ref State

function useRefMonitor(refs) { useEffect(() => { console.log('Ref state change:', refs.map(ref => ({ type: typeof ref, current: ref?.current || 'N/A' }))) }, refs) } 
Enter fullscreen mode Exit fullscreen mode

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

  1. Solves fundamental problems: Completely resolves multi-ref conflicts rather than working around them
  2. Keeps code clean: Avoids complex manual synchronization logic
  3. Improves maintainability: Each ref has clear responsibilities, easy to understand and modify
  4. Enhances reusability: Components can be safely composed and extended
  5. 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 
Enter fullscreen mode Exit fullscreen mode
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> } 
Enter fullscreen mode Exit fullscreen mode

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)