Real-world React ref patterns: click-outside detection, ref arrays, previous value tracking, ResizeObserver, and TypeScript typing.
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.
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>
);
}mousedown rather than click so the close action fires before any click handler on the newly focused element. This prevents flickering when clicking between two dropdowns.ref.current && before calling contains — the ref may be null if the component unmounted between renders.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.
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>
</>
);
}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 with null, so you may want to filter: itemRefs.current = itemRefs.current.filter(Boolean) before use.Map keyed 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);
}}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.
usePrevious hook: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>
);
}ref.current still holds the previous value because the effect hasn't fired yet. After React commits the DOM, the effect updates ref.current to the new value, ready for the next render.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.
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>
);
}resize-observer-polyfill.observe() is called, so size is populated on mount without an extra effect or state initialization.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 |
null:const inputRef = useRef<HTMLInputElement>(null);
// inputRef is RefObject<HTMLInputElement>
// Safe access with optional chaining:
inputRef.current?.focus();MutableRefObject:const timerId = useRef<ReturnType<typeof setTimeout> | null>(null);
// Assign later:
timerId.current = setTimeout(fn, 500);
clearTimeout(timerId.current!);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" />as React.MutableRefObject<T> to force-cast DOM refs — this bypasses null safety. Instead, use optional chaining or an early-return null check.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