🚀 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)