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)