React Ref Patterns
Real-world React ref patterns: click-outside detection, ref arrays, previous value tracking, ResizeObserver, and TypeScript typing.
TL;DR
- 01Use ref.current.contains() to detect clicks outside a component.
- 02Track a previous value by assigning to a ref inside useEffect.
- 03Combine useRef with ResizeObserver for accurate element measurements.
Tips
- 01Wrap the callback prop in useCallback in the parent to keep the dependency array stable and avoid re-registering the document listener on every render.
Warnings
- 01On the very first render, usePrevious returns undefined because no previous value exists. Guard with prevValue !== undefined before using it in comparisons.
Click-Outside Detection
The click-outside pattern is essential for dropdowns, modals, and tooltips. Attach a ref to the container, listen for mousedown on document, and call contains() to decide whether to close.
- Full reusable hook:
import { useRef, useEffect } from 'react'; function useClickOutside(callback) { const ref = useRef(null); useEffect(() => { function handleMouseDown(e) { if (ref.current && !ref.current.contains(e.target)) { callback(); } } document.addEventListener('mousedown', handleMouseDown); return () => document.removeEventListener('mousedown', handleMouseDown); }, [callback]); return ref; } function Dropdown() { const [open, setOpen] = useState(false); const dropdownRef = useClickOutside(() => setOpen(false)); return ( <div ref={dropdownRef}> <button onClick={() => setOpen(o => !o)}>Toggle</button> {open && <ul><li>Item 1</li><li>Item 2</li></ul>} </div> ); } - Use
mousedownrather thanclickso the close action fires before any click handler on the newly focused element. This prevents flickering when clicking between two dropdowns. - Guard with
ref.current &&before callingcontains— the ref may be null if the component unmounted between renders.
Ref Arrays for Dynamic Lists
When you render a list with map and need a ref to each item, you can't call useRef in a loop (that violates the Rules of Hooks). Instead, store an array inside a single ref and populate it with callback refs.
- Hold all item refs in one ref object and assign each slot via a callback ref:
import { useRef } from 'react'; function VirtualList({ items }) { const itemRefs = useRef([]); function scrollToItem(index) { itemRefs.current[index]?.scrollIntoView({ behavior: 'smooth' }); } return ( <> <button onClick={() => scrollToItem(5)}>Jump to #5</button> <ul> {items.map((item, i) => ( <li key={item.id} ref={node => { itemRefs.current[i] = node; }} > {item.label} </li> ))} </ul> </> ); } - The inline callback
node => { itemRefs.current[i] = node; }runs each render, keeping the array in sync when items are added or removed. When a node unmounts React calls the callback withnull, so you may want to filter:itemRefs.current = itemRefs.current.filter(Boolean)before use. - This pattern also works with
Mapkeyed by item ID for sparse or keyed access:const refMap = useRef(new Map()); // ... ref={node => { if (node) refMap.current.set(item.id, node); else refMap.current.delete(item.id); }}
Previous Value Tracking
React has no built-in way to read a previous prop or state value, but a ref updated inside useEffect gives you exactly that — the value from the render that just committed.
- Generic
usePrevioushook:import { useRef, useEffect } from 'react'; function usePrevious(value) { const ref = useRef(undefined); useEffect(() => { ref.current = value; // runs AFTER render, so ref holds previous value during render }, [value]); return ref.current; } function PriceDisplay({ price }) { const prevPrice = usePrevious(price); const direction = price > prevPrice ? '↑' : price < prevPrice ? '↓' : ''; return ( <p> {price} {direction} (was {prevPrice ?? 'N/A'}) </p> ); } - How the timing works: during render,
ref.currentstill holds the previous value because the effect hasn't fired yet. After React commits the DOM, the effect updatesref.currentto the new value, ready for the next render. - Use this to skip an effect on the initial render by comparing current and previous values, or to animate differences between frames.
Measuring with ResizeObserver
ResizeObserver fires whenever a watched element's content box changes size. Combining it with useRef gives you live width and height measurements without polling or window resize events.
- Full hook that returns live dimensions:
import { useRef, useState, useEffect } from 'react'; function useElementSize() { const ref = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); useEffect(() => { const node = ref.current; if (!node) return; const observer = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; setSize({ width: Math.round(width), height: Math.round(height) }); }); observer.observe(node); return () => observer.disconnect(); // always clean up }, []); return [ref, size]; } function ResponsiveCard() { const [cardRef, { width, height }] = useElementSize(); return ( <div ref={cardRef} style={{ resize: 'both', overflow: 'auto', padding: 16 }}> <p>{width} × {height}px — drag the corner to resize</p> </div> ); } - ResizeObserver is available in all modern browsers (Chrome 64+, Firefox 69+, Safari 13.1+). For older environments, polyfill with
resize-observer-polyfill. - The observer fires once immediately after
observe()is called, sosizeis populated on mount without an extra effect or state initialization.
TypeScript: Typing Refs
TypeScript has two ref types. Choosing the right one avoids unsafe non-null assertions and keeps autocompletion accurate.
| Type | current | Created by | Use case |
|---|---|---|---|
RefObject<T> | T | null (readonly) | useRef<T>(null) | DOM nodes React controls |
MutableRefObject<T> | T (writable) | useRef<T>(initialValue) | Timer IDs, counters, previous values |
- DOM element ref — always start with
null:const inputRef = useRef<HTMLInputElement>(null); // inputRef is RefObject<HTMLInputElement> // Safe access with optional chaining: inputRef.current?.focus(); - Mutable value ref — supply a real initial value so TypeScript infers
MutableRefObject:const timerId = useRef<ReturnType<typeof setTimeout> | null>(null); // Assign later: timerId.current = setTimeout(fn, 500); clearTimeout(timerId.current!); - Typing a component that accepts a ref (React 19 style):
import { useRef, useImperativeHandle } from 'react'; type InputHandle = { focus: () => void; clear: () => void; }; type InputProps = React.InputHTMLAttributes<HTMLInputElement> & { ref?: React.Ref<InputHandle>; }; function FancyInput({ ref, ...props }: InputProps) { const innerRef = useRef<HTMLInputElement>(null); useImperativeHandle(ref, () => ({ focus: () => innerRef.current?.focus(), clear: () => { if (innerRef.current) innerRef.current.value = ''; }, })); return <input ref={innerRef} {...props} />; } // Parent: const formRef = useRef<InputHandle>(null); <FancyInput ref={formRef} placeholder="Email" /> - Avoid using
as React.MutableRefObject<T>to force-cast DOM refs — this bypasses null safety. Instead, use optional chaining or an early-return null check.
FAQ
Attach a ref to the dropdown container, then add a mousedown listener on document inside a useEffect. In the handler, call ref.current.contains(e.target) — if it returns false, the click was outside and you can close the dropdown. Remove the listener in the cleanup function returned from useEffect.
Initialise a ref with useRef([]) to hold the array of DOM nodes, then pass each item a callback ref function that assigns node into the array at the correct index: ref={node => { listRef.current[i] = node; }}. This approach works even when the list length changes between renders.
Create a ref with useRef(), then run useEffect(() => { prevRef.current = value; }, [value]) after each render. Because effects run after the render is committed, prevRef.current always holds the value from the previous render cycle when you read it during the current render.
Use ResizeObserver when you need continuous size updates as the element grows or shrinks — for example, when the user resizes the window or when dynamic content changes the element's dimensions. getBoundingClientRect is a one-shot read and won't notify you of future changes. ResizeObserver fires a callback each time the observed element's size changes.
RefObject has a readonly current property typed as T | null — it models a DOM ref that React controls. MutableRefObject has a mutable current typed as T — it models a ref you update yourself, like a timer ID or previous value store. useRef(null) returns RefObject