Skip to main content

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
  • useRef returns the same { current } object on every render. Changing .current does not trigger a re-render
  • createRef allocates a new object on every call, so it only makes sense in class component constructors
  • forwardRef lets a parent pass its ref down into a custom child component
  • useImperativeHandle controls 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

jsx
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.

jsx
// Functional component const ref = useRef(null); // same object, every render const ref = createRef(); // new object, every render - always null

createRef 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 setInterval return value so you can call clearInterval in useEffect cleanup
  • 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

useRefcreateRefforwardRef
Where to useFunctional componentsClass componentsAny component exposing a ref to its parent
Persists across rendersYesNoDepends on inner useRef
Triggers re-renderNoNoNo
Typical useDOM access, mutable valuesDOM access in class componentsPassing ref through component boundary
When to useAlmost alwaysOnly in class constructorsWhen 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:

jsx
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:

jsx
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

  1. Reading ref.current during render
jsx
// 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 }, []);
  1. Storing UI state in a ref
jsx
// 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);
  1. createRef inside a functional component
jsx
// 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} />; }
  1. Missing null check before using ref.current
jsx
// Wrong - crashes if element is not yet mounted inputRef.current.focus(); // Correct inputRef.current?.focus();
  1. forwardRef without useImperativeHandle exposes 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): useRef for 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: forwardRef lets 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

jsx
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

jsx
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

jsx
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?