React 19 useRef
— Deep Dive
useRef
looks tiny on the surface, but used well it prevents bugs, speeds things up, and makes imperative integrations sane.
Table of Contents
Orientation & Warm-Up (≈3 min)
- Tiny teaser — autofocus with
useRef
- The three core truths you should remember right now
- When to reach for a ref (short checklist)
Getting the Mental Model (≈6 min)
- Refs, State, and Plain Variables: Get the Mental Model Right
- Timing: when is a DOM ref populated?
- Practical pattern: storing previous value (
usePrevious
) - Why not mutate refs in render?
- Refs and remounts
- Common beginner mistakes (and how to avoid them)
- Quick decision cheat-sheet
DOM Refs in Depth (≈9 min)
- Basic DOM refs: Getting a DOM Node and Using It Safely
- Object refs: the usual
useRef
pattern - Timing rules: render → commit → effects
- Common DOM-ref tasks & examples
- Callback refs vs object refs: when to use which
- Ensuring cleanup for callback refs
- Forwarding refs (a quick intro)
- Measuring layout:
getBoundingClientRect
vsoffsetHeight
- SSR & refs: what to watch for
- Accessibility & focus management
- Testing DOM refs
- Pitfalls & quick checklist
- Mini recap (so your brain files it away)
Mutable Values & Non-DOM Patterns (≈8 min)
- Refs for Mutable Values: Timers, Previous Values, Flags, and the “Latest” Trick
- Timers:
setTimeout
/setInterval
safe patterns -
usePrevious
(store previous value safely) - “Mounted” flag to prevent setState on unmounted components
- Debounce/throttle IDs stored in refs
- Storing the “latest” callback (avoid stale closures)
- Storing mutable objects or caches
- Controlled animations & RAF loop handles
- Storing previous props/state for comparison (advanced)
- Avoiding overuse: when a ref is a bad idea
- Cleanup checklist for mutable refs
- Mini patterns reference (copy-and-paste)
- Why these patterns are safe with React 19 concurrency
Avoiding Pitfalls (≈6 min)
- Refs vs State: Common Beginner Mistakes (and how to fix them)
- Debugging checklist for ref-vs-state bugs
- Quick decision flow (cheat-sheet)
- Final mini example — ref misused vs fixed
Wrap-Up & Next Steps (≈2 min)
- Wrap-Up — Keep the locker, not the clutter
Why useRef
Actually Deserves Your Attention
Quick truth: useRef
is the hook everyone thinks they “sort of know” — until they need it for something slightly weird and suddenly it’s Either Magic™ or Broken. The good news: it’s neither. useRef
is small, predictable, and one of the most useful escape-hatches in React when used properly.
Think of useRef
as a little mutable box you can keep between renders. Unlike state, mutating the box doesn’t tell React to re-render — which makes it perfect for stuff you want to remember, but that doesn’t need to show up in the UI immediately.
Here’s what this article will do for you:
- Give you a clear mental model so you stop guessing when to use refs.
- Show practical, copy-pasteable examples (DOM refs, timers, previous values,
forwardRef
/useImperativeHandle
). - Explain the tricky edges (callback refs, concurrency, SSR, TypeScript).
- Teach you how refs help with stale closures and
useEvent
in React 19.
Tiny teaser — autofocus with useRef
Let’s warm up with a tiny real-life example: focus an input when a component mounts. This is the canonical “first thing you do with a ref” — and it shows the two big rules you must know right away: .current
is mutable, and you should read DOM refs after the component renders.
Mutable means a value that can be changed after it is created, without replacing the reference that points to it.
import { useRef, useEffect } from "react";
function NameInput() {
const inputRef = useRef(null);
useEffect(() => {
// DOM node is available here
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Type your name…" />;
}
What just happened (plain English):
-
useRef(null)
created a small box{ current: null }
. - React placed the actual DOM node into
inputRef.current
after the component rendered. -
useEffect
runs after render, so callingfocus()
is safe there and your page would load with input element in focus.
The three core truths you should remember right now
-
Refs are mutable containers (
{ current: ... }
) that survive across renders. - Changing a ref does NOT trigger a render. If the UI must update, use state. If you’re tracking an instance/ID/timer handle, use a ref.
-
Refs are populated after render — read DOM refs in
useEffect
oruseLayoutEffect
(the latter if you need measurements before painting).
When to reach for a ref (short checklist)
Use a ref when:
- You need a handle to a DOM node (focus, measure, play audio).
- You want to store a mutable value across renders (timers, previous value, mounted flag).
- You need to expose an imperative API from a component (with
forwardRef
+useImperativeHandle
).
Don’t use a ref when:
- The value should affect rendering. If changing it should re-render the UI, use state.
Section 2 — Refs, State, and Plain Variables: Get the Mental Model Right
Before you type a single useRef
, you must have one strong mental picture. I want you to leave this section able to answer — without hesitation — “Should I use state, a ref, or just a variable?”
The core analogy (keep this in your brain)
-
State (
useState
) = journal entry. It’s recorded in React’s book of truth. When you change it, React re-renders the UI to reflect that new entry. You want this when the UI must change. -
Ref (
useRef
) = a labeled locker inside your component instance. You can put stuff in it and change the contents whenever. The locker persists across renders, but opening/closing the locker does not cause React to re-render. Useful for things you want to remember but that don’t directly drive the UI. -
Plain variable (a local
let
orconst
inside the component) = a scratch note you rewrite every time you render. It resets on each render.
Keep this picture: journal (state) vs locker (ref) vs scratch note (variable).
Property checklist (what refs do — and don’t — do)
- A ref returned from
useRef()
is an object:{ current: <value> }
. -
.current
is mutable and persists across renders. -
Changing
.current
does NOT trigger a re-render. - Refs survive across renders but do not survive a remount (unmount + mount = new ref).
- DOM refs (
ref
on an element) are populated after React renders and commits; read them inuseEffect
/useLayoutEffect
. - Refs are perfect for imperative things: timers, DOM nodes, instance-like values, previous values, and integrating with non-React libraries.
Short, hands-on examples
1) Variables vs refs vs state — see the difference
Paste this into a React app and try clicking the buttons:
import React, { useRef, useState } from "react";
export default function RefVsStateDemo() {
let plainVar = 0; // resets every render
const ref = useRef(0); // persists across renders
const [state, setState] = useState(0); // triggers re-render
function bumpPlainVar() {
plainVar += 1;
console.log("plainVar (after bump):", plainVar);
// UI won't show this change because component didn't re-render
}
function bumpRef() {
ref.current += 1;
console.log("ref.current (after bump):", ref.current);
// UI won't show this change because component didn't re-render
}
function bumpState() {
setState(s => s + 1); // causes re-render, UI updates
}
return (
<div style={{ fontFamily: "sans-serif" }}>
<p>plainVar (scratch note): {plainVar}</p>
<p>ref.current (locker): {ref.current}</p>
<p>state (journal): {state}</p>
<button onClick={bumpPlainVar}>Bump plain var (no render)</button>
<button onClick={bumpRef}>Bump ref.current (no render)</button>
<button onClick={bumpState}>Bump state (causes render)</button>
</div>
);
}
What you’ll observe
- Clicking “Bump plain var” logs a higher number in the console, but the page still shows
plainVar
at the initial value (because the component didn’t re-render). - Clicking “Bump ref.current” logs a higher
ref.current
, but the UI still shows the oldref.current
(again no re-render). - Clicking “Bump state” updates the UI because React re-rendered.
Lesson: Refs and plain variables can both hold values between lines of code, but only state is the reactive one that updates the UI automatically.
2) Refs keep values across renders (but variables don’t)
Here’s a compact demo showing the scratch-note vs locker difference:
function RenderCounter() {
let scratch = 0;
const locker = useRef(0);
locker.current += 1;
scratch += 1;
return (
<div>
<p>scratch (resets each render): {scratch}</p>
<p>locker.current (persists): {locker.current}</p>
<button onClick={() => { /* trigger re-render */ }}>Re-render</button>
</div>
);
}
Every re-render, scratch
will usually display 1
(reset each render), while locker.current
will increment and persist across renders.
Timing: when is a DOM ref populated?
Important practical rule: DOM refs are available after the component renders and commits. That means:
- Don’t try to read a DOM ref during render to measure layout — the DOM node may not exist yet.
-
Do read DOM refs inside
useEffect
oruseLayoutEffect
. UseuseLayoutEffect
if you need measurement before the browser paints (avoids flicker).
Example: measure height safely
import { useRef, useLayoutEffect, useState } from "react";
function MeasureBox() {
const boxRef = useRef(null);
const [height, setHeight] = useState(0);
// useLayoutEffect runs before paint — good for measurements
useLayoutEffect(() => {
if (boxRef.current) {
setHeight(boxRef.current.offsetHeight);
}
}, []);
return (
<>
<div ref={boxRef} style={{ padding: 20, border: "1px solid #ccc" }}>
Measure me
</div>
<p>Measured height: {height}px</p>
</>
);
}
Why useLayoutEffect
here? If you used useEffect
, the browser might briefly paint the unmeasured layout and then update — causing a visible flicker. useLayoutEffect
measures before paint.
** When to use useLayoutEffect
vs useEffect
useLayoutEffect runs synchronously after React updates the DOM but before the browser paints. Because of this, the browser will wait to paint the screen until your layout effect has finished running, which makes it useful for reading layout or making visual adjustments without flicker.
In contrast, useEffect runs after the paint, so it doesn’t block the browser from updating the screen.
Practical pattern: storing previous value (usePrevious
)
A common, extremely useful ref pattern is to remember the previous value of a prop or state without triggering renders:
import { useEffect, useRef } from "react";
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // updated after render
});
return ref.current; // previous value (or undefined on first render)
}
Use it like:
function Demo({ count }) {
const prev = usePrevious(count);
return <div>Now: {count} — Prev: {prev}</div>;
}
usePrevious
works because the ref is updated after the render, so the returned ref.current
reflects the prior render’s value.
Why not mutate refs in render?
Mutating .current
during render is allowed (it’s just JavaScript) but dangerous:
- Render should be a pure calculation of the UI. Mutating during render can create surprising behavior and make debugging harder.
- If you need to set a ref based on rendered DOM, do it in
useEffect
/useLayoutEffect
. - If you mutate to trigger a side-effect, that side-effect should belong in an effect, not inline in render.
Rule of thumb: Use render for describing UI. Use effects for imperative work.
Refs and remounts
Refs persist across renders, but not across unmount + mount. If a component is removed and later re-added, a fresh useRef()
object is created. Don’t rely on refs to survive full unmount cycles.
Common beginner mistakes (and how to avoid them)
-
Using refs to hold UI state — e.g., storing form values in refs and then wondering why the UI never updates.
→ If a change should be visible, useuseState
. -
Expecting ref changes to rerender — you must call
setState
to reflect the change in UI. -
Mutating refs during render to cause side effects — move side effects to
useEffect
. -
Reading DOM ref too early — wait until
useEffect
/useLayoutEffect
. -
Sharing refs across unrelated components — a shared mutable box can create hard-to-track coupling. Use Context carefully.
Quick decision cheat-sheet
- Does updating the value need to change UI? → useState
- Is it an imperative handle, timer id, DOM node, or previous value? → useRef
- Is it temporary per render? → plain variable
Checkpoint 🛑
If you can:
- Explain the locker vs journal vs scratch-note analogy, and
- Show (in code) that refs persist across renders and variables don’t, and that updating refs doesn’t trigger renders —
then your mental model for refs is solid.
Section 3 — Basic DOM refs: Getting a DOM Node and Using It Safely
If you remember one thing from this section, let it be this sentence:
A DOM ref gives you a direct handle to a real DOM node — use it for imperative tasks (focus, measurements, scrolling, third-party libs), and only after the element has been rendered.
Let’s unpack that with clear examples, timing rules, common pitfalls, and the two flavors of refs you’ll use: object refs and callback refs.
3.1 Object refs: the usual useRef
pattern
This is the canonical way to get a DOM node in React.
import { useRef, useEffect } from "react";
function AutofocusInput() {
const inputRef = useRef(null);
useEffect(() => {
// Safe: runs after the first render & DOM commit
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="Type your name…" />;
}
Step-by-step:
-
useRef(null)
creates{ current: null }
. - On render, React sees
ref={inputRef}
and, after committing the DOM, setsinputRef.current
to the actual<input>
DOM node. -
useEffect
runs after the commit, soinputRef.current
is ready and calling.focus()
is safe.
When to do this: autofocus, call .scrollIntoView()
, read .offsetHeight
or .getBoundingClientRect()
, hand a DOM node to a 3rd-party lib.
3.2 Timing rules: render → commit → effects
This is crucial. React renders your component (JSX → virtual DOM → diff), commits updates to the real DOM, then runs useLayoutEffect
(synchronously before paint), and then runs useEffect
(after paint).
- Read DOM before paint: use
useLayoutEffect
(e.g., measure and set layout state to prevent flicker). - Read DOM after paint: use
useEffect
(less likely to cause blocking, okay for lazy measurement).
Example: useLayoutEffect
to measure without flicker
import { useRef, useLayoutEffect, useState } from "react";
function MeasureBox() {
const boxRef = useRef(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
if (boxRef.current) {
setHeight(boxRef.current.getBoundingClientRect().height);
}
}, []);
return (
<>
<div ref={boxRef} style={{ padding: 20 }}>
I change size sometimes
</div>
<p>Measured height: {height}px</p>
</>
);
}
Why useLayoutEffect
here? If you used useEffect
, the browser might paint the unmeasured layout and then repaint after the effect — causing a visible jump. useLayoutEffect
measures before painting to avoid that.
Caveat: useLayoutEffect
runs synchronously and can block painting. Use it only when needed.
I know this sounds like repetition, but reinforcing these concepts is crucial.
3.3 Common DOM-ref tasks & examples
Focus an input on mount — shown above.
Scroll to an element:
function ChatMessage({ id, children }) {
const ref = useRef(null);
// Imagine we call this when a "new message" arrives or on mount
useEffect(() => {
ref.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [children]);
return <div ref={ref} id={`msg-${id}`}>{children}</div>;
}
Play audio/video (a quick example):
function AudioPlayer({ src }) {
const audioRef = useRef(null);
function play() {
audioRef.current?.play();
}
return (
<>
<audio ref={audioRef} src={src} />
<button onClick={play}>Play</button>
</>
);
}
Measure element size after CSS fonts load — sometimes fonts can change layout; you might want to measure in an effect after critical resources load.
3.4 Callback refs vs object refs: when to use which
There are two main ways to attach refs to DOM nodes in React:
-
Object refs —
const r = useRef(null); <div ref={r} />
- Simple, widely used.
- React handles assignment for you (
r.current = node
).
-
Callback refs —
const cb = (node) => { /* do something with node */ }; <div ref={cb} />
- Useful when you need to run code immediately when the node becomes available or you need to manage multiple dynamic refs.
- You can clean up when the node is set to
null
.
Example: callback ref for dynamic lists
Suppose you want to maintain refs for items created dynamically, keyed by id:
function DynamicList({ items }) {
// store refs in an object keyed by id
const nodeMap = useRef({});
function setRef(id) {
return (node) => {
if (node) {
nodeMap.current[id] = node;
} else {
delete nodeMap.current[id];
}
};
}
return (
<ul>
{items.map(item => (
<li key={item.id} ref={setRef(item.id)}>
{item.text}
</li>
))}
</ul>
);
}
Why callback refs here?
- You can set or delete entries when nodes mount/unmount.
- Object refs (an array of refs) become clumsy when list order changes; callback refs let you key them.
3.5 Ensuring cleanup for callback refs
Callback refs can be called with null
when a node unmounts — that’s your chance to clean up, e.g., remove an entry from a map or disconnect a 3rd-party library.
function setRef(id) {
return (node) => {
if (node) {
nodeMap.current[id] = node;
// maybe initialize something with node
} else {
// node is now unmounted
delete nodeMap.current[id];
}
};
}
3.6 Forwarding refs (a quick intro)
Let’s go over this in detail as it is super useful.
Sometimes you build a reusable component — for example, a custom Input
wrapper that adds styling or extra logic.
From the outside, it looks just like a normal React component.
But here’s the problem:
If a parent tries to use a ref
on your custom component, by default React won’t pass that ref
down to the real DOM element inside.
Instead, the ref
will point to your component instance (which usually isn’t useful in function components).
👉 That means this won’t work:
function Parent() {
const ref = useRef(null);
// ❌ Won't work: `ref.current` won't point to the <input>
return <MyInput ref={ref} />;
}
To fix this, React gives us forwardRef
.
It’s a wrapper that lets your component accept a ref from its parent and “forward” it to an inner element.
Example
import React, { forwardRef, useRef, useEffect } from "react";
// This component forwards the ref to its inner <input>
const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy" {...props} />;
});
// Usage
function Parent() {
const ref = useRef(null);
// After the component mounts, focus the input
useEffect(() => {
ref.current?.focus();
}, []);
return <FancyInput ref={ref} />;
}
What’s happening here?
-
Parent
creates aref
. -
Parent
passes thatref
intoFancyInput
. - Because
FancyInput
is wrapped withforwardRef
, React passes the ref along as a second argument. - Inside
FancyInput
, you attach theref
to the real<input>
element. - Now
Parent
can directly access the input’s DOM node viaref.current
.
Why it matters
Without forwardRef
, reusable components are like black boxes — parents can’t reach inside to access the real DOM nodes.
With forwardRef
, you give parents a “handle” to those DOM nodes, which is especially useful for things like:
- Focusing an input
- Measuring size or position
- Animating a DOM node
3.7 Measuring layout: getBoundingClientRect
vs offsetHeight
-
getBoundingClientRect()
returns precise coordinates and fractional pixels; great for positioning logic. -
offsetHeight
andclientWidth
are integers and simpler.
Example: responsive component that measures width
function ResponsiveBox() {
const ref = useRef(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
if (!ref.current) return;
const obs = new ResizeObserver(() => {
setWidth(ref.current.getBoundingClientRect().width);
});
obs.observe(ref.current);
return () => obs.disconnect();
}, []);
return (
<div ref={ref}>
<p>Box width: {width}px</p>
</div>
);
}
Tip: ResizeObserver
is your friend for reacting to size changes without polling.
3.8 SSR & refs: what to watch for
- On the server, refs are
null
because there’s no DOM. - If your component reads DOM nodes unguarded, it will crash during SSR — always read refs inside client-only effects (
useEffect
runs only on client). - Defensive coding:
if (ref.current) { ... }
.
Example guard:
useEffect(() => {
if (!containerRef.current) return;
// safe DOM work here
}, []);
3.9 Accessibility & focus management
Refs are often used to manage focus, but be careful:
- Prefer semantic HTML first (
<button>
,<a>
) — browsers handle focus correctly. - Use refs for focus management when you need to move focus after an action (e.g., open modal → focus first focusable element).
- Don’t trap focus incorrectly — always make sure keyboard users can escape.
Example: focusing modal on open
function Modal({ open, onClose }) {
const contentRef = useRef(null);
useEffect(() => {
if (open) {
contentRef.current?.focus();
}
}, [open]);
if (!open) return null;
return (
<div role="dialog" aria-modal="true">
<div tabIndex={-1} ref={contentRef}>
{/* modal content */}
</div>
</div>
);
}
tabIndex={-1}
makes a non-focusable element programmatically focusable.
3.10 Testing DOM refs
In tests with React Testing Library (recommended), you usually don’t access ref.current
directly. Instead, you assert what the user sees or simulate events. But if you need to test imperative handles:
import { render } from "@testing-library/react";
test("focuses on mount", () => {
const { getByPlaceholderText } = render(<AutofocusInput />);
const input = getByPlaceholderText("Type your name…");
expect(document.activeElement).toBe(input);
});
If you need to test a forwarded ref or imperative API, you can render a component and pass a ref via createRef
:
import { createRef } from "react";
import { render } from "@testing-library/react";
test("exposes focus", () => {
const ref = createRef();
render(<FancyInput ref={ref} />);
ref.current.focus();
expect(document.activeElement).toBe(ref.current);
});
Wrap imperative calls in act()
if needed.
3.11 Pitfalls & quick checklist
- ✅ Read DOM refs in effects (
useEffect
/useLayoutEffect
) — not during render. - ✅ Use
useLayoutEffect
when you must measure before paint (avoid flicker). - ✅ Clean up listeners or 3rd-party instances in effect cleanup.
- ✅ Use callback refs for dynamic lists or when you need immediate assignment/cleanup.
- ✅ Don’t rely on refs during SSR.
- ✅ If the value should cause a UI change, use state, not refs.
3.12 Mini recap (so your brain files it away)
- Object refs (
useRef
) are the simplest way to grab a DOM node. - Callback refs are the flexible option when you need custom assignment/cleanup or dynamic keyed refs.
- Use
useLayoutEffect
for synchronous, pre-paint measurements; otherwiseuseEffect
is fine. - Forward refs when building components that should expose inner nodes to parents.
Section 4 — Refs for Mutable Values: Timers, Previous Values, Flags, and the “Latest” Trick
useRef
is not just for DOM nodes. It’s also the correct place to keep mutable, instance-like values that must survive across renders but shouldn’t cause re-renders — things like timer IDs, the previous value of a prop, “is mounted” flags, and the current version of a callback.
Think of these as the internal, private fields of your component instance. They exist so you can safely coordinate asynchronous work, cleanup, and imperative interactions without turning the UI into a mess.
We’ll walk through the most common and useful patterns.
Most beginners are exhausted after DOM related scenarios and ignore this learning. I hope you don’t repeat that mistake as it is more useful in practice than you realize at this stage.
4.1 — Timers: setTimeout
/ setInterval
safe patterns
Problem: You start an interval in a component, but when the component unmounts you forget to clear it — or you clear the wrong one.
Ref solution: Store the timer ID in a ref so you can clear it later, and tidy it up in the effect cleanup.
import { useEffect, useRef } from "react";
function Clock({ intervalMs = 1000 }) {
const timerRef = useRef(null);
const tick = () => {
console.log("tick", Date.now());
};
useEffect(() => {
timerRef.current = setInterval(tick, intervalMs);
// cleanup on unmount or when intervalMs changes
return () => {
clearInterval(timerRef.current);
};
}, [intervalMs]);
return <div>Open console to see ticks</div>;
}
Why a ref?
- The timer ID is an implementation detail; changing it shouldn’t re-render the UI.
- A ref persists across renders so your cleanup can always find the current timer.
Bonus: a start/stop API using ref
function TimerControl() {
const timerRef = useRef(null);
function start() {
if (timerRef.current == null) {
timerRef.current = setInterval(() => console.log("tick"), 1000);
}
}
function stop() {
clearInterval(timerRef.current);
timerRef.current = null;
}
useEffect(() => () => stop(), []); // cleanup on unmount
return (
<>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}
4.2 — usePrevious
(store previous value safely)
We introduced usePrevious
earlier. Let’s make a robust, reusable hook. This pattern is super helpful for animations, diffs, or conditional logic that depends on prior value.
import { useEffect, useRef } from "react";
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}); // runs after every render
return ref.current;
}
Why this works
- The ref is updated after render, so the returned
ref.current
reflects the previous render’s value. - No re-renders are triggered — it’s a read-only snapshot for one render after the effect.
Example usage
function Price({ price }) {
const prevPrice = usePrevious(price);
return (
<div>
<p>Now: {price}</p>
<p>Previous: {prevPrice ?? "—"}</p>
</div>
);
}
4.3 — “Mounted” flag to prevent setState on unmounted components
A classic bug: an async callback resolves after the component unmounted, and you call setState
— React warns about it. A mounted flag avoids calling setState
after unmount.
import { useEffect, useRef, useState } from "react";
function DataFetcher({ url }) {
const mountedRef = useRef(false);
const [data, setData] = useState(null);
useEffect(() => {
mountedRef.current = true;
fetch(url)
.then((r) => r.json())
.then((json) => {
if (mountedRef.current) setData(json);
});
return () => {
mountedRef.current = false;
};
}, [url]);
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
Why prefer this to try/catch hacks
- It’s explicit and readable.
- It avoids warnings and potential memory/logic leaks.
- Note: if you use
abortController
for fetch, that’s even better — but the mounted-flag is a lightweight fallback.
4.4 — Debounce/throttle IDs stored in refs
Debounce implementations commonly need to remember the timer ID. Refs are perfect.
import { useRef, useEffect } from "react";
function useDebouncedEffect(callback, deps, delay) {
const timerRef = useRef(null);
useEffect(() => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
callback();
}, delay);
return () => clearTimeout(timerRef.current);
}, [...deps, delay, callback]);
}
Note: If callback
is recreated on every render, include a stable reference (see next pattern — storing “latest callback” in a ref). Or ask consumers to wrap callback
with useCallback
.
4.5 — Storing the “latest” callback (avoid stale closures)
If you add a native event listener or an interval that calls a handler later, you might be calling an old version of the handler (stale closure). A common pattern is to store the latest callback in a ref and have the timer/event read ref.current()
.
import { useEffect, useRef } from "react";
function useEventCallback(fn) {
const fnRef = useRef(fn);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
// return a stable function that always calls the latest fn
return (...args) => fnRef.current?.(...args);
}
Example: interval that always calls latest callback
function IntervalTicker({ onTick, ms = 1000 }) {
const savedCallback = useRef(onTick);
useEffect(() => {
savedCallback.current = onTick;
}, [onTick]);
useEffect(() => {
function tick() {
savedCallback.current();
}
const id = setInterval(tick, ms);
return () => clearInterval(id);
}, [ms]);
}
Why this works
- The interval function references
savedCallback.current
every time it runs, and that ref always points to the latestonTick
function thanks to the effect that updates it. - The
setInterval
itself is created only once (unlessms
changes), avoiding re-subscription churn.
This pattern is the backbone of many advanced hooks and pairs well with useEvent
in React 19 — useEvent
itself provides a stable + fresh function for handlers.
4.6 — Storing mutable objects or caches
Refs are great for mutable caches (e.g., Map/WeakMap) that don’t need to render state.
function useElementSizeCache() {
const cacheRef = useRef(new Map());
function getSize(node) {
if (cacheRef.current.has(node)) {
return cacheRef.current.get(node);
}
const rect = node.getBoundingClientRect();
cacheRef.current.set(node, rect);
return rect;
}
return { getSize, cacheRef };
}
Caveat: Be careful to clean up entries if nodes unmount to avoid memory leaks. WeakMap is useful when keys are DOM nodes since it allows GC:
const cacheRef = useRef(new WeakMap());
4.7 — Controlled animations & RAF loop handles
When you do requestAnimationFrame (RAF) loops, you must keep the ID so you can cancel it. The ID belongs in a ref.
import { useRef, useEffect } from "react";
function useAnimationLoop(callback) {
const rafRef = useRef(null);
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
let mounted = true;
function loop(time) {
if (!mounted) return;
callbackRef.current(time);
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
mounted = false;
cancelAnimationFrame(rafRef.current);
};
}, []);
}
4.8 — Storing previous props/state for comparison (advanced)
You can combine usePrevious
with other refs to detect patterns like “value increased since last render” and trigger effects accordingly.
function useValueChangeDetector(value) {
const prev = usePrevious(value);
useEffect(() => {
if (prev !== undefined && value > prev) {
console.log("value increased");
}
}, [value, prev]);
}
4.9 — Avoiding overuse: when a ref is a bad idea
Refs are powerful, but they’re an imperative escape hatch. Don’t use refs for things that are better expressed declaratively:
- Don’t store UI-visible data in refs — use state.
- Don’t use refs to replicate what a controlled component does for you.
- Don’t use refs to fight React; use them to integrate with imperative APIs.
If you find yourself writing many ref-manipulations to keep UI consistent, consider redesigning to a declarative approach.
4.10 — Cleanup checklist for mutable refs
Whenever you store external resources in refs, remember cleanup:
- Timers (
clearTimeout
,clearInterval
) - RAF (
cancelAnimationFrame
) - Native event listeners (
removeEventListener
) - 3rd-party instances (charts, maps) — call destroy/cleanup methods
- Clear refs to
null
where appropriate to avoid accidental reuse
Example cleanup in effect:
useEffect(() => {
timerRef.current = setInterval(...);
return () => {
clearInterval(timerRef.current);
timerRef.current = null;
};
}, []);
4.11 — Mini patterns reference (copy-and-paste)
-
Timer ID:
const timerRef = useRef(null);
-
Mounted flag:
const mountedRef = useRef(false);
set in effect -
Latest callback:
const cbRef = useRef(fn)
+ effect to update it -
WeakMap cache:
const cacheRef = useRef(new WeakMap())
-
RAF ID:
const rafRef = useRef(null)
4.12 — Why these patterns are safe with React 19 concurrency
React 19 may run renders more than once before committing, but refs are synchronous JS objects local to a render’s component instance. The patterns above hold because:
- Effects run after render commits (and you do your subscription/cleanup there).
- Updating a ref is synchronous and only affects that component instance.
- Reducible race conditions (like async callbacks resolving after unmount) are addressed by mounted flags and proper cleanup.
That said, keep reducers/logic pure and do side effects only in effects.
Checkpoint 🛑
If you can:
- Keep timer IDs & RAF IDs in refs and clean them up on unmount,
- Use
usePrevious
to access prior render values, and - Use a
latestCallback
ref to avoid stale closures for interval/event handlers —
then you’ve learned the most practical, everyday uses of refs that save you countless bugs.
Section 5 — Refs vs State: Common Beginner Mistakes (and how to fix them)
Refs are glorious little escape hatches — but because they don’t cause renders, beginners often reach for them when they really need useState
. The result is UI that looks broken, racey, or completely mysterious to debug.
This section is a gentle but firm smackdown of the most common anti-patterns, with before / after examples you can paste into a sandbox and learn from.
Mistake 1 — Storing UI-visible data in refs
Symptom: You update a ref and the UI doesn’t update. You wonder why your form input or counter never shows the new value.
Why it’s wrong: Refs don’t trigger renders. If the UI depends on a value, that value should live in state.
Bad (using ref for visible value):
function BadCounter() {
const countRef = useRef(0);
return (
<>
<p>Count: {countRef.current}</p>
<button onClick={() => { countRef.current += 1; }}>
Increment
</button>
</>
);
}
Click the button → console shows countRef.current
increased, but the page shows the old number because React never re-rendered.
Good (use state):
function GoodCounter() {
const [count, setCount] = useState(0);
return (
<>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</>
);
}
Now the UI updates as expected.
Rule: If changing a value should change what the user sees — use state.
Mistake 2 — Using refs to “force” behavior instead of rethinking flow
People sometimes store a value in a ref and then call forceUpdate
or a hacky setState({})
to re-render. That’s brittle.
Bad:
const rerender = useReducer(s => s + 1, 0)[1]; // hacky force
const fooRef = useRef(0);
function onClick() {
fooRef.current++;
rerender();
}
Better: Model the thing properly. If the user should see foo
, keep it in state. If you need both a visible value and an imperative handle, keep them both (state for render, ref for impl detail).
Mistake 3 — Mutating refs during render for side effects
Mutating refs while rendering (outside of effects) is tempting but dangerous: it breaks the purity of render and causes surprising behavior during concurrent rendering.
Bad (mutating in render):
function BadRenderMutate({ value }) {
const ref = useRef();
ref.current = expensiveDerived(value); // avoid: side-effect-like work in render
// ...
}
Why this is bad: render should be a pure calculation. Do expensive work or assign refs in useEffect
/useLayoutEffect
when it’s imperial/imperative.
Good:
function GoodRenderMutate({ value }) {
const ref = useRef(null);
useEffect(() => {
ref.current = expensiveDerived(value);
}, [value]);
// ...
}
Mistake 4 — Using refs to control forms you should make controlled
Uncontrolled inputs (using defaultValue
and refs) are fine for simple one-off cases, but when form values must be validated, displayed elsewhere, or submitted, controlled inputs (state) are preferable.
Bad uncontrolled (overused):
function BadForm() {
const nameRef = useRef();
function onSubmit() {
// read from DOM node
alert(nameRef.current.value);
}
return <input ref={nameRef} defaultValue="Alice" />;
}
This is okay for tiny throwaways, but becomes a nightmare when you need real-time validation, formatting, or cross-field logic.
Good controlled:
function GoodForm() {
const [name, setName] = useState("");
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
Controlled inputs make the single source of truth explicit and testable.
Mistake 5 — Confusing “latest value” trick with “state needs to re-render”
A common correct use of refs is to store the latest callback or latest value for async handlers. But don’t use that as a way to avoid re-rendering when the UI needs updating.
Correct use (latest callback):
const savedCb = useRef(fn);
useEffect(() => { savedCb.current = fn; }, [fn]);
useEffect(() => {
const interval = setInterval(() => savedCb.current(), 1000);
return () => clearInterval(interval);
}, []);
This is great. It fixes stale closures for background callbacks.
Incorrect use (to hide UI changes):
If you update savedCb.current
with new data and expect the UI to reflect that without state, you’re misusing refs.
Mistake 6 — Sharing refs like mutable global state (via Context) casually
Refs can be shared through Context, but that couples components to mutation and makes reasoning harder. If the value must be read by many components, consider state or a well-designed pub/sub.
If you do provide a shared ref: document the tiny API and protect invariants. Prefer exposing functions (dispatch
) instead of exposing raw mutable boxes.
Mistake 7 — Using refs to avoid writing a reducer or state logic
If you’re juggling many related values and trying to keep them synchronized with refs, it’s usually time for useReducer
. Refs make sync fragile; reducers make it explicit.
Debugging checklist for ref-vs-state bugs
If the UI is not updating the way you expect:
- Did you mutate a ref and expect a re-render? → probably the bug.
- Are you reading a ref inside render that’s set in
useEffect
? → timing issue. - Did you mutate a ref in render? → move that to an effect.
- Is the value shared across components but mutated directly? → consider Context + dispatch.
Quick decision flow (cheat-sheet)
- Does changing the value need to change the UI? → useState
- Is it an imperative handle, timer id, DOM node, or “latest callback”? → useRef
- Is it temporary per render only? → plain variable
Final mini example — ref misused vs fixed
Bad (ref for input value, UI doesn’t update elsewhere):
function Bad() {
const nameRef = useRef("Alice");
return (
<>
<input defaultValue={nameRef.current} />
<p>Greeting: Hello, {nameRef.current}</p> {/* never updates */}
<button onClick={() => (nameRef.current = "Bob")}>Change</button>
</>
);
}
Fixed (state):
function Good() {
const [name, setName] = useState("Alice");
return (
<>
<input value={name} onChange={(e) => setName(e.target.value)} />
<p>Greeting: Hello, {name}</p> {/* updates */}
<button onClick={() => setName("Bob")}>Change</button>
</>
);
}
Wrap-Up — Keep the locker, not the clutter
useRef
looks small, but once you get comfortable with the patterns in this article — timers in refs, usePrevious
, latest-callback refs, callback refs, and forwardRef
/useImperativeHandle
— you’ll see it as the tidy little toolbox it is. Remember: refs don’t cause renders. If a value should be visible, put it in state. If it’s an implementation detail (ID, DOM node, cache, mounted flag), stash it in a ref.
Practice (two-minute lab)
Try this: convert one small component in your app that currently stores an implementation detail in state (timer id, element size, cached value) and move that detail into a ref. Observe fewer re-renders and cleaner logic.
What’s next
In the next article, we’ll switch gears and explore useContext — React’s built-in way to share data across a component tree without prop drilling. You’ll learn real-world patterns, common pitfalls, and how to use context effectively in larger apps.
Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Want video demos? Subscribe on YouTube: @LearnAwesome