Refs in React (useRef, createref, forwardref)
Refs in React are objects that hold a direct reference to a DOM node or component instance, outside of React's rendering cycle.
Theory
TL;DR
- A ref is a direct phone line to a DOM element: you bypass the rendering queue and call it straight
useRefreturns the same{ current }object on every render. Changing.currentdoes not trigger a re-rendercreateRefallocates a new object on every call, so it only makes sense in class component constructorsforwardReflets a parent pass its ref down into a custom child componentuseImperativeHandlecontrols what the parent can actually access through that ref- Rule: need
focus(),play(), an interval ID, or a previous value? Use ref. Need to update the UI? Use state
Quick example
import { useRef } from 'react';
function VideoPlayer() {
const videoRef = useRef(null); // starts as { current: null }
const play = () => {
videoRef.current.play(); // direct DOM call, no re-render
};
return (
<div>
<video ref={videoRef} src="demo.mp4" />
<button onClick={play}>Play</button>
</div>
);
}After mount, videoRef.current points to the real <video> element. The button calls the native video.play() API without any state update.
Key difference from state
State changes go through React's virtual DOM diffing and schedule a re-render. A ref just holds .current, a plain mutable property React does not watch. You write to it, React ignores it, nothing re-renders. React sets ref.current during the commit phase, after it finishes updating the real DOM. This makes refs the right tool when you need to talk to the DOM directly, or store mutable data that has no effect on what the user sees.
useRef vs createRef
useRef stores its value in the fiber node's memoizedState linked list. The same object comes back on every render because React does not reset hooks on updates. createRef allocates a fresh { current: null } every time it runs. In a functional component that means a new object every render, and the ref is never properly attached.
// Functional component
const ref = useRef(null); // same object, every render
const ref = createRef(); // new object, every render - always nullcreateRef belongs in class component constructors, where it runs exactly once.
When to use refs
- Focus and text selection:
inputRef.current.focus()after a modal opens or a keyboard shortcut fires - Media and canvas:
videoRef.current.play(),canvasRef.current.getContext('2d')for frame-by-frame drawing - Mutable flags without render: whether autoplay succeeded, whether the user has scrolled past a section, a previous prop value
- Timer IDs: store the
setIntervalreturn value so you can callclearIntervalinuseEffectcleanup - Third-party libraries: Chart.js, Video.js, any canvas-based tool that takes a real DOM node at init time
I've seen forms where pagination state was kept in a ref to avoid extra renders. It worked fine until the component needed to display the current page number, and suddenly it was always 1. If the value affects what the user sees, it belongs in state.
API comparison
useRef | createRef | forwardRef | |
|---|---|---|---|
| Where to use | Functional components | Class components | Any component exposing a ref to its parent |
| Persists across renders | Yes | No | Depends on inner useRef |
| Triggers re-render | No | No | No |
| Typical use | DOM access, mutable values | DOM access in class components | Passing ref through component boundary |
| When to use | Almost always | Only in class constructors | When parent needs direct access to child DOM |
forwardRef
By default you cannot put a ref prop on a custom component. React swallows it silently. forwardRef wraps the component and passes the ref as a second argument to the render function:
import { forwardRef, useRef } from 'react';
const CustomInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});
function Parent() {
const inputRef = useRef(null);
return (
<div>
<CustomInput ref={inputRef} placeholder="Type here" />
<button onClick={() => inputRef.current.focus()}>Focus</button>
</div>
);
}Now inputRef.current is the real <input>. The parent can call any DOM method on it.
useImperativeHandle
Total DOM access is sometimes more than you want to hand over. useImperativeHandle lets you define exactly what the parent receives:
import { forwardRef, useRef, useImperativeHandle } from 'react';
const Video = forwardRef((props, ref) => {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
playPause: () =>
videoRef.current.paused
? videoRef.current.play()
: videoRef.current.pause(),
}), []);
return <video ref={videoRef} {...props} />;
});
function Player() {
const videoRef = useRef(null);
return (
<>
<Video ref={videoRef} src="demo.mp4" />
<button onClick={() => videoRef.current.playPause()}>Toggle</button>
</>
);
}The parent gets playPause and nothing else. The raw DOM node stays inside the child. This pattern is standard in component libraries like React Player.
How React attaches refs
React sets ref.current during the commit phase, after it finishes writing to the real DOM. Before mount, ref.current is null. After unmount, React resets it to null again. So reading ref.current during render always gives you a stale or null value. Read it inside useEffect or event handlers, never in the render body.
In React 18 with StrictMode, components mount twice in development. useRef survives both mounts because it lives in the same fiber object. But effects clean up and run twice. If you store an observer or a subscription in a ref, clean it up in the useEffect return function or you get a leak.
Common mistakes
- Reading
ref.currentduring render
// Wrong - ref.current is null here, element does not exist yet
function Component() {
const ref = useRef(null);
console.log(ref.current); // null
return <div ref={ref}>text</div>;
}
// Correct
useEffect(() => {
console.log(ref.current); // real DOM node
}, []);- Storing UI state in a ref
// Wrong - display goes stale, no re-render happens
const valueRef = useRef('');
valueRef.current = e.target.value;
// Correct
const [value, setValue] = useState('');
setValue(e.target.value);createRefinside a functional component
// Wrong - new ref every render, ref never sticks
function Bad() {
const ref = createRef();
return <input ref={ref} />;
}
// Correct
function Good() {
const ref = useRef(null);
return <input ref={ref} />;
}- Missing null check before using
ref.current
// Wrong - crashes if element is not yet mounted
inputRef.current.focus();
// Correct
inputRef.current?.focus();forwardRefwithoutuseImperativeHandleexposes the entire DOM node
The parent can read .value, call .blur(), modify .style directly. For a simple internal component that is usually fine. For a component in a shared library or design system, always limit the surface with useImperativeHandle.
Real-world usage
- React Aria (Adobe):
useReffor focus management in accessible modals and dropdowns - React Hook Form: stores input refs for validation without re-rendering on every keystroke (10M+ weekly npm downloads)
- Recharts:
forwardReflets parent components attach resize observers to chart containers - Framer Motion: refs drive imperative scroll and animation triggers
- Video.js: passes ref to a
<canvas>element for WebGL overlay rendering
Follow-up questions
Q: What is the difference between useRef and createRef?
A: useRef stores its object in the fiber's hook state and returns the same reference every render. createRef allocates a new { current: null } on every call. In a functional component that means the ref resets every render and is never usefully attached.
Q: When does ref.current get set?
A: During the commit phase, after React has updated the real DOM. It is null before mount and resets to null after unmount. Never read it during render.
Q: Why combine useImperativeHandle with forwardRef?
A: To expose a controlled API instead of the full DOM node. The parent gets only the methods you define, which prevents accidental direct DOM manipulation from outside the component boundary.
Q: Can refs cause memory leaks?
A: Yes. If you store an interval ID, observer, or event listener in a ref and never clear it in useEffect cleanup, it stays alive after the component unmounts. Always return a cleanup function.
Q: How do refs behave in concurrent React 18?
A: Refs attach after commitRoot finishes all work. Time slicing does not affect ref stability because refs live outside the fiber mutation phase. A low-priority suspended render does not reset ref.current. The attachment order is deterministic regardless of how many times rendering is interrupted.
Examples
Autoplay detection
import { useRef, useEffect } from 'react';
function AutoplayVideo({ src }) {
const videoRef = useRef(null);
const wasPlayingRef = useRef(false); // mutable flag, no re-render needed
useEffect(() => {
const video = videoRef.current;
video
.play()
.then(() => {
wasPlayingRef.current = true; // stored without triggering update
})
.catch(() => {
console.log('Autoplay blocked by browser policy');
});
}, [src]);
return <video ref={videoRef} src={src} muted />;
}Chrome blocks autoplay on unmuted video, so the muted attribute is required. wasPlayingRef stores the result without triggering a re-render because the autoplay status does not need to appear in the UI. This is a common pattern in production video players.
Custom hook for previous value
import { useRef, useEffect, useState } from 'react';
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // runs after render, so ref holds last render's value
}, [value]);
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Now: {count}, before: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}The useEffect runs after render. So when the component returns, ref.current still holds the previous render's value. On the next render, that old value is what gets returned. The timing asymmetry is the whole mechanism.
Video player with controlled imperative API
import { forwardRef, useRef, useImperativeHandle } from 'react';
const VideoPlayer = forwardRef(({ src }, ref) => {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
seek: (seconds) => { videoRef.current.currentTime = seconds; },
}), []);
return <video ref={videoRef} src={src} />;
});
function App() {
const playerRef = useRef(null);
return (
<>
<VideoPlayer ref={playerRef} src="demo.mp4" />
<button onClick={() => playerRef.current.play()}>Play</button>
<button onClick={() => playerRef.current.seek(30)}>Skip 30s</button>
</>
);
}The parent has play, pause, and seek. It cannot read .currentTime, .buffered, or .duration. That controlled boundary is exactly what useImperativeHandle is for.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.