What Is useRef?
useRef is a React hook that returns a mutable object with a .current property. Unlike state, changing .current does not trigger a re-render. This makes it perfect for two things:
- Accessing DOM elements directly
- Storing mutable values that persist across renders without causing re-renders
import { useRef } from 'react';
function TextInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current.focus(); // Direct DOM access
};
return (
<div>
<input ref={inputRef} type="text" placeholder="Click the button..." />
<button onClick={focusInput}>Focus Input</button>
</div>
);
}
useRef vs useState
This is the most important distinction to understand.
function RefVsState() {
const [stateCount, setStateCount] = useState(0);
const refCount = useRef(0);
const incrementState = () => {
setStateCount(s => s + 1);
// Triggers re-render, UI updates
};
const incrementRef = () => {
refCount.current += 1;
// NO re-render, UI does NOT update
console.log('Ref value:', refCount.current);
};
return (
<div>
<p>State: {stateCount}</p>
<p>Ref: {refCount.current}</p> {/* Shows stale value until re-render */}
<button onClick={incrementState}>+1 State</button>
<button onClick={incrementRef}>+1 Ref (check console)</button>
</div>
);
}
| useState | useRef | |
|---|---|---|
| Triggers re-render | Yes | No |
| Returns | [value, setter] | { current: value } |
| Persistence | Across renders | Across renders |
| Update method | setValue(newValue) | ref.current = newValue |
| Async access | Closure captures value from that render | Always latest value |
| Use for | Data the UI depends on | DOM access, timers, previous values |
useState flow:
setValue(5) -> React schedules re-render -> component runs -> reads new value
useRef flow:
ref.current = 5 -> Nothing happens (no render)
Next render (triggered by something else) -> ref.current is still 5
DOM Access with Refs
Basic DOM Ref
function VideoPlayer({ src }) {
const videoRef = useRef(null);
const play = () => videoRef.current.play();
const pause = () => videoRef.current.pause();
const restart = () => {
videoRef.current.currentTime = 0;
videoRef.current.play();
};
return (
<div>
<video ref={videoRef} src={src} />
<div>
<button onClick={play}>Play</button>
<button onClick={pause}>Pause</button>
<button onClick={restart}>Restart</button>
</div>
</div>
);
}
Scrolling to an Element
function ScrollToSection() {
const section1Ref = useRef(null);
const section2Ref = useRef(null);
const section3Ref = useRef(null);
const scrollTo = (ref) => {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div>
<nav>
<button onClick={() => scrollTo(section1Ref)}>Section 1</button>
<button onClick={() => scrollTo(section2Ref)}>Section 2</button>
<button onClick={() => scrollTo(section3Ref)}>Section 3</button>
</nav>
<section ref={section1Ref} style={{ height: '100vh' }}>
<h2>Section 1</h2>
</section>
<section ref={section2Ref} style={{ height: '100vh' }}>
<h2>Section 2</h2>
</section>
<section ref={section3Ref} style={{ height: '100vh' }}>
<h2>Section 3</h2>
</section>
</div>
);
}
Measuring DOM Elements
function MeasuredBox() {
const boxRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
if (boxRef.current) {
const { width, height } = boxRef.current.getBoundingClientRect();
setDimensions({ width: Math.round(width), height: Math.round(height) });
}
}, []);
return (
<div>
<div
ref={boxRef}
style={{ padding: 20, border: '1px solid #ccc', display: 'inline-block' }}
>
This box has content of varying size.
</div>
<p>Width: {dimensions.width}px, Height: {dimensions.height}px</p>
</div>
);
}
Canvas Drawing
function DrawingCanvas() {
const canvasRef = useRef(null);
const isDrawing = useRef(false);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
const startDraw = (e) => {
isDrawing.current = true;
ctx.beginPath();
ctx.moveTo(e.offsetX, e.offsetY);
};
const draw = (e) => {
if (!isDrawing.current) return;
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
};
const stopDraw = () => {
isDrawing.current = false;
};
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDraw);
canvas.addEventListener('mouseleave', stopDraw);
return () => {
canvas.removeEventListener('mousedown', startDraw);
canvas.removeEventListener('mousemove', draw);
canvas.removeEventListener('mouseup', stopDraw);
canvas.removeEventListener('mouseleave', stopDraw);
};
}, []);
return (
<canvas
ref={canvasRef}
width={400}
height={300}
style={{ border: '1px solid #ccc', cursor: 'crosshair' }}
/>
);
}
useRef for Instance Variables
Refs are perfect for storing values that need to persist across renders but shouldn't trigger re-renders. Think of them as "instance variables" for function components.
Tracking Previous Value
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current; // Returns previous value (before this render's effect runs)
}
// Usage
function PriceDisplay({ price }) {
const prevPrice = usePrevious(price);
const direction = prevPrice !== undefined
? price > prevPrice ? 'up' : price < prevPrice ? 'down' : 'same'
: 'same';
return (
<div>
<span className={`price price-${direction}`}>
${price.toFixed(2)}
</span>
{prevPrice !== undefined && (
<span className="price-change">
(was ${prevPrice.toFixed(2)})
</span>
)}
</div>
);
}
How usePrevious works:
Render 1: value=10
ref.current = undefined (initial)
return undefined (no previous value)
After render: ref.current = 10
Render 2: value=20
ref.current = 10 (set in previous render's effect)
return 10 (previous value!)
After render: ref.current = 20
Render 3: value=15
ref.current = 20
return 20
After render: ref.current = 15
Storing Timer IDs
function Debouncer() {
const [value, setValue] = useState('');
const [debouncedValue, setDebouncedValue] = useState('');
const timerRef = useRef(null);
const handleChange = (e) => {
const newValue = e.target.value;
setValue(newValue);
// Clear previous timer
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// Set new timer
timerRef.current = setTimeout(() => {
setDebouncedValue(newValue);
}, 500);
};
// Cleanup on unmount
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
return (
<div>
<input value={value} onChange={handleChange} />
<p>Debounced: {debouncedValue}</p>
</div>
);
}
Counting Renders (Debugging)
function RenderCounter({ children }) {
const renderCount = useRef(0);
renderCount.current += 1;
return (
<div>
<div style={{ fontSize: 12, color: '#888' }}>
Renders: {renderCount.current}
</div>
{children}
</div>
);
}
Storing Latest Callback (Avoiding Stale Closures)
function useLatestCallback(callback) {
const ref = useRef(callback);
ref.current = callback; // Always points to latest version
// Return a stable function that calls the latest callback
return useCallback((...args) => ref.current(...args), []);
}
// Usage: event handlers that don't need to be in useEffect deps
function Search({ onResults }) {
const stableOnResults = useLatestCallback(onResults);
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/search');
ws.onmessage = (e) => {
stableOnResults(JSON.parse(e.data));
};
return () => ws.close();
}, [stableOnResults]); // stableOnResults never changes
}
forwardRef: Passing Refs to Child Components
By default, refs only work on DOM elements, not custom components. forwardRef lets a component receive a ref from its parent and forward it to a DOM element inside.
The Problem
// This does NOT work โ ref on a custom component doesn't attach to anything
function Parent() {
const inputRef = useRef(null);
return (
<div>
<CustomInput ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Focus</button>
{/* ERROR: inputRef.current is null */}
</div>
);
}
function CustomInput(props) {
return <input {...props} className="custom-input" />;
}
The Solution: forwardRef
import { forwardRef, useRef } from 'react';
const CustomInput = forwardRef(function CustomInput({ label, ...props }, ref) {
return (
<div className="input-group">
{label && <label>{label}</label>}
<input ref={ref} {...props} className="custom-input" />
</div>
);
});
function Parent() {
const inputRef = useRef(null);
return (
<div>
<CustomInput ref={inputRef} label="Username" placeholder="Enter name" />
<button onClick={() => inputRef.current.focus()}>Focus</button>
</div>
);
}
forwardRef with TypeScript
type InputProps = {
label?: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
const FormInput = forwardRef<HTMLInputElement, InputProps>(
function FormInput({ label, error, ...props }, ref) {
return (
<div>
{label && <label>{label}</label>}
<input ref={ref} {...props} />
{error && <p className="error">{error}</p>}
</div>
);
}
);
useImperativeHandle: Controlling Exposed API
Sometimes you want to expose a limited API to the parent instead of the full DOM element. useImperativeHandle customizes what the ref exposes.
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
const Timer = forwardRef(function Timer(props, ref) {
const [time, setTime] = useState(0);
const intervalRef = useRef(null);
useImperativeHandle(ref, () => ({
start() {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
setTime(t => t + 1);
}, 1000);
},
stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
},
reset() {
clearInterval(intervalRef.current);
intervalRef.current = null;
setTime(0);
},
getTime() {
return time;
},
}), [time]);
return <p>{time}s</p>;
});
// Parent only sees start/stop/reset/getTime โ NOT the DOM element
function App() {
const timerRef = useRef(null);
return (
<div>
<Timer ref={timerRef} />
<button onClick={() => timerRef.current.start()}>Start</button>
<button onClick={() => timerRef.current.stop()}>Stop</button>
<button onClick={() => timerRef.current.reset()}>Reset</button>
</div>
);
}
Without useImperativeHandle:
ref.current = <p> DOM element
Parent can do: ref.current.innerHTML, ref.current.style, etc.
(Too much access โ breaks encapsulation)
With useImperativeHandle:
ref.current = { start, stop, reset, getTime }
Parent can only use the methods you expose
(Clean API โ component controls what's accessible)
Callback Refs
For cases where you need to know when a ref is attached or detached, use a callback ref instead of useRef.
function MeasureOnMount() {
const [height, setHeight] = useState(0);
// Callback ref โ called when element mounts/unmounts
const measuredRef = useCallback((node) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<div>
<div ref={measuredRef} style={{ padding: 20 }}>
<p>Line 1</p>
<p>Line 2</p>
<p>Line 3</p>
</div>
<p>Measured height: {height}px</p>
</div>
);
}
When to Use Callback Refs vs useRef
| useRef | Callback ref | |
|---|---|---|
| Syntax | const ref = useRef(null) | const ref = useCallback(node => {...}, []) |
| Notification | None when attached | Called when attached/detached |
| Use case | Access element after mount | Measure on mount, conditional elements |
| Updates | Manual | Automatic when element appears/disappears |
When Refs Are Appropriate
Good Uses
- Managing focus โ
inputRef.current.focus() - Triggering animations โ
elementRef.current.animate(...) - Integrating with third-party DOM libraries โ D3, chart libraries, map libraries
- Storing timer/interval IDs โ no re-render needed
- Storing previous props/state โ
usePreviouspattern - Storing latest callback โ avoiding stale closures in intervals/subscriptions
- Measuring elements โ
getBoundingClientRect() - Controlling media elements โ play, pause, seek
- Scrolling โ
scrollIntoView()
Bad Uses (Use State Instead)
- Storing data the UI depends on โ use
useState - Tracking form values โ use controlled components
- Manually updating DOM text/content โ let React handle it
- Hiding/showing elements โ use conditional rendering
- Adding/removing CSS classes โ use state-driven
className
// BAD โ imperatively updating DOM (fighting React)
function Bad() {
const pRef = useRef(null);
const updateText = () => {
pRef.current.textContent = 'Updated!'; // React doesn't know about this
pRef.current.style.color = 'red';
};
return <p ref={pRef}>Original</p>;
}
// GOOD โ let React manage the DOM
function Good() {
const [text, setText] = useState('Original');
const [isHighlighted, setIsHighlighted] = useState(false);
const update = () => {
setText('Updated!');
setIsHighlighted(true);
};
return <p style={{ color: isHighlighted ? 'red' : 'inherit' }}>{text}</p>;
}
useRef in Custom Hooks
useClickOutside
function useClickOutside(handler) {
const ref = useRef(null);
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) return;
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [handler]);
return ref;
}
// Usage
function Popover({ onClose, children }) {
const popoverRef = useClickOutside(onClose);
return (
<div ref={popoverRef} className="popover">
{children}
</div>
);
}
useHover
function useHover() {
const ref = useRef(null);
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleEnter = () => setIsHovered(true);
const handleLeave = () => setIsHovered(false);
element.addEventListener('mouseenter', handleEnter);
element.addEventListener('mouseleave', handleLeave);
return () => {
element.removeEventListener('mouseenter', handleEnter);
element.removeEventListener('mouseleave', handleLeave);
};
}, []);
return [ref, isHovered];
}
// Usage
function HoverCard() {
const [cardRef, isHovered] = useHover();
return (
<div
ref={cardRef}
style={{
padding: 20,
background: isHovered ? '#f0f0f0' : '#fff',
transition: 'background 200ms',
}}
>
{isHovered ? 'Hovering!' : 'Hover me'}
</div>
);
}
useIntersectionObserver
function useIntersectionObserver(options = {}) {
const ref = useRef(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(element);
return () => observer.disconnect();
}, [options.threshold, options.rootMargin]);
return [ref, isIntersecting];
}
// Usage: Lazy loading
function LazySection({ children }) {
const [ref, isVisible] = useIntersectionObserver({ threshold: 0.1 });
return (
<div ref={ref}>
{isVisible ? children : <div style={{ height: 200 }}>Loading...</div>}
</div>
);
}
useFocusTrap
function useFocusTrap() {
const ref = useRef(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const focusableElements = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
};
element.addEventListener('keydown', handleKeyDown);
firstFocusable?.focus();
return () => element.removeEventListener('keydown', handleKeyDown);
}, []);
return ref;
}
// Usage: Modal with focus trap
function Modal({ onClose, children }) {
const trapRef = useFocusTrap();
return (
<div className="modal-overlay">
<div ref={trapRef} className="modal" role="dialog" aria-modal="true">
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
Key Takeaways
- useRef returns
{ current: value }โ persists across renders, changes don't trigger re-renders - DOM access โ attach ref to an element with the
refprop, then useref.currentto access the DOM node - Instance variables โ store timer IDs, previous values, latest callbacks, and any mutable value that doesn't affect the UI
- forwardRef โ lets parent components access DOM elements inside child components
- useImperativeHandle โ limits what the parent can access through the ref (clean API)
- Callback refs โ use when you need to know when an element mounts/unmounts
- Don't use refs for things React should manage โ text content, visibility, styling
- Refs always hold the latest value โ no stale closure problem like with state
- Modifying ref.current during render is allowed but should be idempotent (same result if called twice, for Strict Mode)
- Custom hooks use refs heavily โ
useClickOutside,useHover,usePrevious,useIntersectionObserver