Puneet Sharma - Frontend Developer & UI Engineer
Puneet Sharma
Frontend Dev & UI Engineer · 16+ yrs · pixel-perfect HTML, React & WordPress

React useRef Explained: 8 Practical Uses Beyond the DOM

Learn React useRef with 8 practical examples for DOM access, persistent values, avoiding re-renders, timers, focus management, and more.
React useRef Explained: 8 Practical Uses Beyond the DOM

Most useRef tutorials stop at one example — focusing an input — and leave the impression that useRef is just "the DOM access hook." That's true of maybe a third of what it's actually for. The real idea behind useRef is simpler and more general: it gives a component a box that survives re-renders but never causes one. Sometimes that box holds a DOM node. Just as often it holds an interval ID, a previous value, a render count, or a method you want a parent to call directly. Once that distinction is clear, a whole category of "how do I do X in React" questions turns out to have the same one-line answer.

The 8 demos below are all real, running React — drag a slider, start a ticker, click outside a menu — so you can watch each use of useRef actually behave instead of reading about it secondhand.

Every embed also has a Fork & Edit button that opens the exact snippet, already running, in My Code — FWD Tools' free in-browser editor. One note on the code you'll see: these demos skip a build step, so the live JS uses React.createElement directly instead of JSX — the logic is identical to the JSX version you'd actually write, just without a compiler in between.

The one-sentence rule

const ref = useRef(initialValue);

ref.current;              // read or write it any time — never triggers a render
// compare: setState(next) always schedules a render

useRef(initialValue) returns one plain object, { current: initialValue }, and that same object is handed back on every render of the component — React never replaces it, never diffs it, never re-renders anything because you changed .current. That's the entire mechanism. Everything below is just a different reason to want a box with that one property.

1. Focusing a Real DOM Node

Click the button — it calls a real DOM method, .focus(), on the actual input element. No state, no re-render.

The rule:

const inputRef = useRef(null);

// on the element: ref={inputRef}
inputRef.current.focus();

This is the canonical use, and worth understanding precisely before the rest: passing a ref as an element's ref prop tells React "once you've created the real DOM node for this element, put it in ref.current." After the component mounts, inputRef.current is the actual <input> element sitting in the page — the same object document.querySelector would return — so any real DOM method (.focus(), .scrollIntoView(), .click()) works on it directly.

Best for: focus management, scrolling an element into view, reading a canvas or video element to call its native API. Tip: ref.current is null until after the first render commits — never read it during the render itself, only inside event handlers or effects.

2. A Value That Doesn't Trigger a Re-render

Both counters go up on every click. Only one of them makes the paragraph below actually update on screen.

The rule:

const [stateClicks, setStateClicks] = useState(0);
const refClicks = useRef(0);

setStateClicks(stateClicks + 1);   // schedules a re-render
refClicks.current += 1;            // does not

This is the demo that makes the core distinction impossible to unsee: the ref's number is genuinely changing, every single click, exactly like the state's number — you can prove it by forcing an unrelated re-render and watching the box update to the correct value it had all along. It just never causes a render on its own, so the screen only catches up once something else asks React to re-render.

Best for: a value the UI never needs to reflect live — a click counter feeding an analytics batch that flushes every 10 seconds, a flag checked only inside an event handler. Tip: if you catch yourself reading ref.current to decide what to render, that's a sign it should be state instead — refs are for values only imperative code ever needs to see.

3. Remembering the Previous Value

Drag the slider. The "previous" value is always one render behind — a ref updated inside an effect, after the render it's shown in.

The rule:

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => { ref.current = value; });
  return ref.current;
}

The ordering here is the entire trick. During a given render, ref.current still holds whatever was stored on the previous render — the effect that updates it to the new value only runs after the DOM has committed, which is too late to affect the render that just happened. So the hook hands back "the value as of last time" precisely because writing the new value is deliberately deferred past the point where it could overwrite what this render needed to read.

Best for: comparing a prop or state value against what it was last render — detecting a transition (was closed, now open), animating based on direction of change, or logging "changed from X to Y." Tip: this exact 4-line hook is one of the most commonly hand-rolled utilities in React codebases — worth recognizing on sight in a codebase you didn't write.

4. Fixing a Stale Closure in setInterval

Start the ticker, then flip the mode a few times before it logs. The "stale" side reports whatever mode was active when the interval was created; the "fixed" side always reports the mode right now.

The rule:

const modeRef = useRef(mode);
useEffect(() => { modeRef.current = mode; }, [mode]);

useEffect(() => {
  const id = setInterval(() => {
    console.log(modeRef.current);   // always current
  }, 1200);
  return () => clearInterval(id);
}, []);   // intentionally not re-created when mode changes

The interval is set up once and deliberately not recreated on every mode change — recreating it would reset the timer's own clock every time. But that means its callback's own closure only ever sees the mode value from the render when it was created — a real, common bug, not a contrived one. The fix isn't to add mode back to the dependency array; it's to give the callback a ref it can read fresh every tick, updated by a separate effect that's allowed to re-run as often as mode changes.

Best for: any long-lived callback — an interval, a WebSocket message handler, an IntersectionObserver callback — that needs to act on the latest value of something without being torn down and rebuilt every time that value changes. Tip: this pattern has a name, "the latest ref pattern," and it's worth building once as a small useLatest(value) hook rather than re-deriving it inline every time you hit this bug.

5. Uncontrolled Input — Read Once, Not on Every Keystroke

Type in either field. The left one re-renders on every keystroke (watch the counter); the right one only reads its value when you click Submit.

The rule:

const inputRef = useRef(null);
// on the element: ref={inputRef} defaultValue=""

function handleSubmit() {
  console.log(inputRef.current.value);   // read once, on demand
}

A controlled input (value + onChange) re-renders the component on every keystroke, because React has to own the value to keep it in sync — for most fields that cost is invisible, but it's a real cost. An uncontrolled input just lets the browser's own DOM hold the value the whole time, the same as plain HTML always has, and a ref is only used to reach in and read .value the one moment it's actually needed. Fewer renders, less code, at the price of not knowing the live value until you ask for it.

Best for: a field whose value only matters on submit — a search box, a one-shot form field, any input where you don't need to validate or mirror the value as the user types. Tip: the moment you need live validation, a character counter, or to disable a button based on the current text, that's the signal to switch that specific field back to controlled — the two approaches mix fine within one form.

6. Imperative Scrolling and Measuring

The buttons call real DOM methods on the scroll container through a ref — nothing here is tracked in state, so scrolling itself never triggers a React re-render.

The rule:

const boxRef = useRef(null);

boxRef.current.scrollTo({ top: 0, behavior: 'smooth' });
boxRef.current.scrollTop;         // read the current position, on demand
boxRef.current.scrollHeight;

Scroll position is a textbook case for a ref rather than state: it changes dozens of times a second while a user drags or flicks, and almost nothing about a typical UI needs to re-render on every one of those changes. This demo never listens for a scroll event at all — it just reaches into the DOM node on demand, either to command it (scrollTo) or to read its current numbers (scrollTop, scrollHeight), exactly when a button click asks for it.

Best for: scroll-to-top buttons, "jump to section" navigation, infinite-scroll boundary checks triggered from a throttled listener rather than every frame. Tip: if you do need to react continuously to scroll position (a progress bar, a parallax effect), attach a real scroll event listener in an effect and throttle it — don't try to poll a ref on a timer instead.

7. Click-Outside Detection with a Ref

Open the menu, then click anywhere outside it. A ref on the menu's own DOM node is how the outside-click check knows what "outside" even means.

The rule:

const wrapRef = useRef(null);

useEffect(() => {
  function onPointerDown(e) {
    if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
  }
  document.addEventListener('pointerdown', onPointerDown);
  return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);

The check itself is a single native DOM method, .contains() — "is the thing that was clicked inside this element?" — and a ref is what makes that element reachable from a document-level listener in the first place. The listener is only attached while the menu is actually open (the effect returns early otherwise), and it's cleaned up the moment the menu closes or the component unmounts, so an already-closed menu never keeps a stray global listener running.

Best for: dropdowns, popovers, custom select menus, any small floating panel that should dismiss itself the instant focus moves elsewhere. Tip: listen on pointerdown rather than click — it fires before focus moves, which avoids a class of bugs where clicking a different focusable element both closes the menu and immediately reopens something else in the same interaction.

8. Exposing an Imperative API with useImperativeHandle

The parent's buttons never touch a DOM node directly — they call .focus() and .clear(), two methods the child component chose to expose on its own ref.

The rule:

const FancyInput = forwardRef((props, ref) => {
  const innerRef = useRef(null);
  useImperativeHandle(ref, () => ({
    focus: () => innerRef.current.focus(),
    clear: () => { innerRef.current.value = ''; innerRef.current.focus(); }
  }));
  return <input ref={innerRef} />;
});

This is the one entry on this list where a component deliberately does not hand over its raw DOM node to whoever holds its ref. forwardRef lets a component accept a ref from its parent at all, and useImperativeHandle replaces what that ref actually points to — instead of the real <input> element, the parent's fancyRef.current becomes the small object returned here, with exactly the two methods the child decided to expose. The parent can call .clear(); it can't reach in and read .value directly, because that was never part of the API the child chose to publish.

Best for: a reusable component (a custom input, a modal, a video player wrapper) that needs to offer a few specific imperative actions to its parent without exposing its entire internal DOM structure. Tip: reach for this only when a prop genuinely can't express the interaction — most "imperative-seeming" needs (open/closed state, current value) are still better modeled as a prop and a callback than as a ref method.

Common pitfalls

  • Reading ref.current during render to decide what to show. Refs don't trigger re-renders, so a UI that depends on a ref's value can silently go stale — demo #2 shows exactly this gap. If a value needs to affect what's on screen, it belongs in state.
  • Mutating ref.current and expecting the screen to update on its own. Nothing is wrong with the mutation itself — the screen just won't reflect it until some other state change forces a re-render, as demo #2's "force a re-render" button demonstrates.
  • Recreating an interval or listener every time a dependency changes, just to avoid a stale closure. Demo #4 shows the better fix: keep the effect's own lifecycle stable and give its callback a ref it can read fresh, instead of tearing down and rebuilding the whole subscription.
  • Attaching a click-outside listener that's never removed. Demo #7's effect only exists while open is true and always returns a cleanup function — skipping either half leaks a document-level listener per mount.
  • Reaching for useImperativeHandle when a prop would do. It's the right tool for demo #8's .focus()/.clear() case specifically because there's no natural prop for "do this now" — most component APIs are better served by props and callbacks than by ref methods.

Frequently asked questions

Does changing ref.current ever cause a re-render?

No, never, by design — that's the one property that defines what a ref is. Demo #2 proves it directly: the ref's number changes every click exactly like the state's number does, but the screen only updates once something else (state) triggers a render.

When should I use useRef instead of useState?

When the value needs to persist across renders but the UI never needs to visually reflect it changing — a DOM node reference, an interval ID, a previous value for comparison, a render counter. The moment you need the screen to update when the value changes, it needs to be state, not a ref.

Why is ref.current null on the first render?

Because the DOM node it will eventually point to doesn't exist yet until after React commits the render to the actual page. Reading a DOM ref's .current only makes sense inside an effect or an event handler, both of which run after that commit — never read it in the middle of a render function.

What's the difference between useRef and createRef?

useRef is the hook, for use inside function components — it returns the exact same ref object on every render of that component instance. createRef predates hooks and creates a brand-new ref object every time it's called, which makes it unsuitable for use inside a function component's body (it would reset every render); it's still occasionally used in class components, where useRef isn't available at all.

Can a ref hold something other than a DOM node?

Yes — a ref is just a mutable box; nothing about it is DOM-specific. Demos #2 through #5 all store a plain number, a previous value, or nothing DOM-related at all. Only demos #1, #6, and #7 actually put a DOM node inside one, via the ref prop on an element.

Every demo above ships its full HTML, CSS and JS in the HTML / CSS / JS tabs in its own top bar. The fastest way to actually internalize these is to click Fork & Edit on demo #4 — the stale closure one — delete the modeRef fix, and watch the "fixed" column start reporting stale values too. Once you've got a version you like, save it to My Code and it's yours to keep, tweak further, or reuse in a real project.

Want to go deeper than a single embed? The React Playground is FWD Tools' full in-browser React sandbox — a real editor with instant preview, built for exactly this kind of edit-and-see learning, without the eight-tab constraint of a blog post.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer and the creator of FWD Tools and WebDevPuneet. Follow him on X/Twitter

Post a Comment