
Manipulating DOM with useRef: The Right Way to Escape React
A complete guide to accessing DOM directly in React using useRef, avoiding common pitfalls, and mastering advanced patterns like forwardRef and useImperativeHandle.

A complete guide to accessing DOM directly in React using useRef, avoiding common pitfalls, and mastering advanced patterns like forwardRef and useImperativeHandle.
HTML is just text. Browser converts it into a Tree Structure (DOM) to manipulate it with JS.

Obsessively wrapping everything in `useMemo`? It might be hurting your performance. Learn the hidden costs of memoization and when to actually use it.

Deployed your React app and getting 404 on refresh? Here's why Client-Side Routing breaks on static servers and how to fix it using Nginx, AWS S3, Apache, and Netlify redirects. Includes a debugging guide.

Rebuilding a real house is expensive. Smart remodeling by checking blueprints (Virtual DOM) first.

When I first started building with React, I ran into a wall pretty quickly. I had a modal that needed to auto-focus an input when it opened. Easy enough, right? I just grabbed the element the old-fashioned way — document.getElementById('my-input').focus() — and called it a day. Except it didn't work. Not consistently, anyway. Sometimes it worked, sometimes I got a null reference error, and I had no idea why.
The reason, I eventually figured out, is that React has its own rendering cycle. When I called getElementById, the DOM element might not exist yet — React hadn't finished painting it. I was reaching into React's kitchen and grabbing things before it had finished cooking.
That's when I started actually learning useRef.
The situations that send you looking for this hook are pretty common:
input.focus()).element.scrollIntoView()).video.play()).All of these need direct DOM access — something React deliberately keeps out of your hands unless you use the right tool.
React provides useRef — a hook specifically designed for safe, lifecycle-aware DOM access.
import { useRef, useEffect } from 'react';
function SearchInput() {
// 1. Initialize with null (Generic required in TS)
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// 3. Access current property ONLY after mount
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
// 2. Attach to ref attribute
return <input ref={inputRef} placeholder="Type here..." />;
}
The flow here matters. When you call useRef(null), you get back an object shaped like { current: null }. When you attach it to a JSX element via ref={inputRef}, React fills in inputRef.current with the actual DOM node right after it mounts. When the component unmounts, it sets current back to null.
This is why you access it inside useEffect — that runs after mount, so current is guaranteed to be populated. Trying to access it during render is too early; the DOM isn't ready yet.
The most important distinction is whether the change triggers a re-render.
const countRef = useRef(0);
const handleClick = () => {
countRef.current += 1;
console.log(countRef.current);
// Logs 1, 2, 3... but the screen won't update!
};
This trips people up at first. The value is changing, but nothing on screen reflects it. That's the point — some data doesn't need to trigger a repaint. A setInterval ID, for instance, is just a number you need later to call clearInterval. Storing that in useState would cause unnecessary re-renders every time you update it. useRef is the right tool for that job.
One practical pattern: using useRef to remember the previous render's value.
function PreviousValue({ value }: { value: number }) {
const prevValueRef = useRef<number>(value);
useEffect(() => {
prevValueRef.current = value;
});
return (
<p>
Now: {value}, Before: {prevValueRef.current}
</p>
);
}
This works because useEffect runs after render, so when you read prevValueRef.current during render, it still holds the value from the previous render. Then the effect updates it for next time. Subtle, but useful for animations and diffing logic.
Here's a scenario that catches everyone: you build a reusable MyInput component and want to attach a ref to it from the parent.
// Won't work — custom components don't forward refs by default
<MyInput ref={inputRef} />
React will actually warn you about this. The fix is forwardRef, which lets you explicitly pass the ref from the parent down into the child's internal DOM node.
import { forwardRef } from 'react';
const MyInput = forwardRef<HTMLInputElement, { label: string }>((props, ref) => {
return (
<div className="input-wrapper">
<label>{props.label}</label>
<input ref={ref} type="text" />
</div>
);
});
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<>
<MyInput ref={inputRef} label="Name" />
<button onClick={focusInput}>Focus!</button>
</>
);
}
This is essential when you're building any kind of component library. Without it, consumers of your components have no way to access the underlying DOM elements — which sometimes they legitimately need to do.
Sometimes you don't want to hand over the whole DOM node. Maybe you want to expose a limited, controlled API — custom methods like open(), close(), reset(), or validate() instead of raw DOM access.
useImperativeHandle is exactly for this.
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
export interface ModalHandle {
open: () => void;
close: () => void;
}
const CustomModal = forwardRef<ModalHandle, { children: React.ReactNode }>((props, ref) => {
const [isOpen, setIsOpen] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false)
}));
if (!isOpen) return null;
return (
<div className="modal">
<div className="content">
{props.children}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
</div>
);
});
function Page() {
const modalRef = useRef<ModalHandle>(null);
return (
<>
<button onClick={() => modalRef.current?.open()}>Open Modal</button>
<CustomModal ref={modalRef}>
<h1>Hello World</h1>
</CustomModal>
</>
);
}
The parent calls open() and close() without ever touching the modal's internal state directly. The modal stays encapsulated. This becomes valuable as components get more complex — a modal might handle focus trapping, scroll locking, animation timing, and accessibility concerns internally. The parent shouldn't have to know about any of that. It just calls open().
This is the one that took me the longest to internalize. useRef is powerful, but using it for the wrong things actively makes your code worse.
React is declarative — you describe what the UI should look like given a certain state, and React figures out how to make that happen. Refs are imperative — you directly command the DOM to change. Mixing these inappropriately causes bugs.
Bad (imperative):const openModal = () => {
modalRef.current.style.display = 'block';
modalRef.current.classList.add('open');
};
Good (declarative):
const [isOpen, setIsOpen] = useState(false);
return (
<div className={`modal ${isOpen ? 'open' : ''}`} style={{ display: isOpen ? 'block' : 'none' }}>
...
</div>
);
The problem with the imperative version isn't just style — it's that React doesn't know you changed those styles. On the next re-render, React will overwrite your manual DOM changes based on what it thinks the state is. You'll get flickering, inconsistent behavior, and bugs that are genuinely hard to track down.
Rule of thumb: if it affects what the user sees, it should go through state. Refs are for things React can't control through state — browser APIs like focus, scroll, media playback, and integrating external libraries.
What if you need to do something the moment an element appears in the DOM — not just when the component mounts, but specifically when that particular element is added?
Regular useRef won't notify you when current changes. That's where Callback Refs come in.
function MeasureExample() {
const [height, setHeight] = useState(0);
const measuredRef = (node: HTMLHeadingElement) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
};
return (
<>
<h1 ref={measuredRef}>Header to Measure</h1>
<h2>Height: {Math.round(height)}px</h2>
</>
);
}
Instead of passing a ref object, you pass a function. React calls that function with the DOM node when the element mounts, and with null when it unmounts. I used this pattern when building accordion components — to animate height, you need the actual rendered height, and a callback ref gives you that at exactly the right moment.
One of the most common practical uses I've found for useRef is pairing it with Intersection Observer for scroll-triggered effects.
function FadeInSection({ children }: { children: React.ReactNode }) {
const sectionRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = sectionRef.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(element);
}
},
{ threshold: 0.1 }
);
observer.observe(element);
return () => {
observer.unobserve(element);
};
}, []);
return (
<div
ref={sectionRef}
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(20px)',
transition: 'opacity 0.5s ease, transform 0.5s ease',
}}
>
{children}
</div>
);
}
The cleanup function in useEffect is critical here. If you forget to call observer.unobserve(element) when the component unmounts, you get a memory leak — the observer keeps holding a reference to the element even after it's gone. Always clean up after yourself in effects.
Another underappreciated use for useRef is debouncing. If you're building a search input that fires an API call after the user stops typing, you need to store a timer ID between renders.
function SearchInput() {
const [query, setQuery] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
console.log('Searching:', value);
}, 400);
};
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
return <input value={query} onChange={handleChange} placeholder="Search..." />;
}
Storing the timer ID in useState would cause a re-render every time you set a new timer — which defeats the purpose of debouncing and adds unnecessary overhead. useRef lets you update the stored ID silently.
A lightweight copy of the real DOM kept in memory. React modifies this Virtual DOM first, then calculates the most efficient way to update the browser's actual DOM (Reconciliation). Direct manipulation via useRef bypasses this process, which is why you have to be careful — React won't know about changes you make directly to the DOM.
Form elements like <input> where the data is handled by the DOM itself, not by React state. You use a Ref to read their values. This is "Uncontrolled" because React doesn't own the value.
React doesn't attach event listeners to individual nodes. Instead, it uses a single listener at the root (Event Delegation) and wraps native browser events in cross-browser abstractions called Synthetic Events.
If you attach a manual event listener (addEventListener) to a ref element but forget to remove it (removeEventListener) when the component unmounts, that listener stays alive and holds references in memory. The useEffect cleanup function is where you prevent this.
After spending real time with useRef, I'd summarize it like this: it's a controlled escape hatch. Use it when you need to touch things outside React's declarative model — browser APIs, DOM measurements, third-party integrations, timers.
The mental model I settled on: if a change should update the screen, it goes through state. If it's internal bookkeeping that the UI doesn't need to know about, or if it's a browser API that React can't abstract, that's a job for ref.
Mastering forwardRef and useImperativeHandle takes it further — letting you build components with clean, intentional APIs rather than leaky implementations. That shift in thinking, from "expose the DOM node" to "expose a specific interface," is what separates a component that works from a component that's actually reusable.
Use the exit when you need it. Don't use it just because the door is there.