Skip to main content

Command Palette

Search for a command to run...

Mastering useRef in React: A Practical Guide

Updated
7 min readView as Markdown
Mastering useRef in React: A Practical Guide

f you’re diving into React, you’ve likely come across the ‎useRef hook. But what exactly is ‎useRef(), and how can it make your life easier? In this post we’ll cover what ‎useRef is, walk through five real-world use cases, and compare it against the alternatives so its advantages are obvious.

What is useRef() really?

At its core, ‎useRef() returns a mutable object with a ‎.current property that persists across component renders. Unlike state, changing ‎.current does not trigger a re-render. That makes refs useful for holding values you want to survive between renders without touching the UI. Think of ‎useRef as a “box” that can hold any value.

Key points:

  • The ref object is stable — the same object is returned on every render.

  • Updating ‎ref.current is synchronous and doesn’t cause renders.

  • Common uses: DOM access, storing mutable values (timers, previous values), avoiding stale closures, and exposing imperative methods.

Use case 1 — DOM manipulation

One of the most common uses for ‎useRef is directly accessing a DOM node — for example, focusing an input when a component mounts.

Before (‎getElementById):

import React, { useEffect, useState } from 'react';

const FocusInput = () => {
  const [inputValue, setInputValue] = useState('');

  useEffect(() => {
    document.getElementById('input').focus();
  }, []);

  return <input id="input" value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;
};

After (‎useRef):

import React, { useEffect, useRef, useState } from 'react';

const FocusInput = () => {
  const [inputValue, setInputValue] = useState('');
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;
};

Why it’s better: no global ‎id lookups, no risk of colliding IDs across components, and the reference is scoped to this component. React hands you the node directly.

Use case 2 — Storing previous values

useRef can hold a previous value across renders without triggering an extra render.

Before (using state for the previous value):

import React, { useState, useEffect } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  const [prevCount, setPrevCount] = useState(0);

  useEffect(() => {
    setPrevCount(count);
  }, [count]);

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {prevCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

The ‎setPrevCount call schedules a second render every time ‎count changes — wasteful for a value that’s purely informational.

After (‎useRef via a reusable ‎usePrevious hook):

import React, { useState, useEffect, useRef } from 'react';

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  }, [value]);
  return ref.current; // returns the value from the previous render
}

const Counter = () => {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {prevCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

Why it’s better: the effect writes to the ref after render, so during render ‎usePrevious still returns the prior value — exactly what you want. And there’s no extra render, because updating a ref doesn’t schedule one. This ‎usePrevious pattern is the canonical, reusable version of the idea.

Use case 3 — Storing interval/timeout IDs

Storing a timer ID in state is unnecessary and causes re-renders. A ref is the right home for it.

Before (timer ID in state — not ideal):

import React, { useState, useEffect } from 'react';

const TimerExample = () => {
  const [seconds, setSeconds] = useState(0);
  const [timerId, setTimerId] = useState(null);

  useEffect(() => {
    const id = setInterval(() => setSeconds(s => s + 1), 1000);
    setTimerId(id);
    return () => clearInterval(id);
  }, []);

  return <div>Seconds: {seconds}</div>;
};

After (timer ID in a ref):

import React, { useState, useEffect, useRef } from 'react';

const TimerExample = () => {
  const [seconds, setSeconds] = useState(0);
  const timerRef = useRef(null);

  useEffect(() => {
    timerRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(timerRef.current);
  }, []);

  return <div>Seconds: {seconds}</div>;
};

Why it’s better: the timer ID is an implementation detail the UI never displays, so it doesn’t belong in state. The ref keeps it around for cleanup without any extra renders.

Use case 4 — Avoiding stale closures

When a callback registered once (inside ‎setInterval, an event listener, or a third-party callback) needs the latest state or props, a ref can hold the most recent value without re-creating the callback.

import React, { useState, useEffect, useRef } from 'react';

// Stub for illustration — replace with your real data fetching.
const fetchData = (q) => {
  console.log('Fetching results for:', q);
};

const Polling = () => {
  const [query, setQuery] = useState('react');
  const latestQueryRef = useRef(query);

  // Keep the ref in sync with the latest query on every change.
  useEffect(() => {
    latestQueryRef.current = query;
  }, [query]);

  // This interval is created once and never re-created.
  useEffect(() => {
    const id = setInterval(() => {
      // Reading the ref gives the latest query, not the stale one
      // captured when the interval was first set up.
      fetchData(latestQueryRef.current);
    }, 5000);
    return () => clearInterval(id);
  }, []);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
};

Why it matters: the empty dependency array means the interval closure captures ‎query only once — as ‎'react'. Without the ref, it would poll forever with the stale value. The ref gives the closure a live window into the current state.

Use case 5 — Integrating with third-party libraries or imperative methods

Refs shine at the boundary between React and the imperative world. Two common cases:

  1. Passing a DOM node to a non-React library

Many charting, mapping, or animation libraries want a raw DOM element to mount into. A ref hands them exactly that.

import React, { useEffect, useRef } from 'react';
import Chart from '/chart.js';

const ChartCanvas = ({ data }) => {
  const canvasRef = useRef(null);

  useEffect(() => {
    const chart = new Chart(canvasRef.current, {
      type: 'bar',
      data,
    });
    return () => chart.destroy();
  }, [data]);

  return <canvas ref={canvasRef} />;
};
  1. Exposing imperative methods to a parent

With ‎useImperativeHandle, a child can expose specific methods to its parent. As of React 19, ‎ref is passed as a normal prop, so ‎forwardRef is no longer required:

// React 19+: ref is just a prop — no forwardRef needed.
import React, { useImperativeHandle, useRef } from 'react';

const FancyInput = ({ ref, ...props }) => {
  const inputRef = useRef(null);
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
  }));
  return <input ref={inputRef} {...props} />;
};

// Parent
// const ref = useRef(null);
// <FancyInput ref={ref} />
// ref.current.focus();

If you’re on React 18 or earlier, wrap the component in ‎forwardRef instead:

import React, { forwardRef, useImperativeHandle, useRef } from 'react';

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

Best practices and pitfalls

Do use refs for:

  • Accessing DOM nodes.

  • Storing mutable values that don’t affect rendering (timers, IDs, latest values).

  • Avoiding stale closures.

  • Interop with non-React libraries and imperative APIs.

Don’t use refs to:

  • Replace state when the UI depends on the value. If a change should show up on screen, use state.

  • Force re-renders — refs intentionally do not trigger them.

Common pitfalls:

  • Reading ‎ref.current before it’s assigned — guard with optional chaining (‎ref.current?.…) or null checks.

  • Using refs for controlled input values — prefer controlled components (state) unless you deliberately want an uncontrolled input.

  • Reaching for ‎id selectors for DOM access — refs are cleaner, scoped, and safer.

Conclusion

useRef is a small but powerful hook for keeping mutable values between renders without causing re-renders — the right tool for DOM access, timers and IDs, previous values, avoiding stale closures, and bridging imperative libraries. The rule of thumb: use state for anything the UI displays, and refs for everything mutable that it doesn’t.

Give these patterns a try and watch how they sharpen your components. Happy coding!

More from this blog

Engineering at JoBins

167 posts

Welcome to Engineering at JoBins — where we share the stories, insights, and lessons from building a world-class hiring platform. From system design and scalability to developer tools and team culture, our engineers write about the real challenges we solve every day. Whether you're a curious developer or a fellow builder, we hope our experiences inspire and inform your own engineering journey.