In one minute
JSX creates element objects, not DOM nodes, so during render there’s no node to hand to a non-React library. React gives you the real node through the ref prop: either a ref callback (React calls it with the node and calls its returned cleanup when the node goes away) or a useRef object whose .current React fills in. When you set something up from an effect, the dependency array decides when it’s torn down and redone, and React compares each dependency with Object.is: numbers and strings by value, objects and functions by identity. An object created during render is “new” every time.
You’ll be able to: connect a DOM library to React, and keep its effect from re-running on unrelated renders.
1function ProductPhoto({ scale, speed }) {2 const ref = useRef(null)3 const options = { scale, speed } // new every render4 useEffect(5 () => attachZoom(ref.current, options),6 [options],7 )8 return <img ref={ref} alt="Ceramic Mug" />9}- eventClick “Quantity”. Unrelated state changes, so the product re-renders.
- render phase
const options = { scale, speed } // new every renderThe render creates a newoptionsobject with the same contents. - commit phase
[options],React compares each dependency withObject.is, and objects compare by identity. Recorded:Object.is(a, b) → falsefor two equal objects. - effects
() => attachZoom(ref.current, options),So React tears the zoom down and sets it up again, on every click.
1function ProductPhoto({ scale, speed }) {2 const ref = useRef(null)3 useEffect(4 () => attachZoom(ref.current, { scale, speed }),5 [scale, speed],6 )7 return <img ref={ref} alt="Ceramic Mug" />8}- eventThe same unrelated click.
- commit phase
[scale, speed],The dependencies are plain numbers, and numbers compare by value:Object.is(1.2, 1.2)is true, so nothing “changed”. - effectsThe effect doesn’t run. Recorded: nothing logged for two clicks. Only “Bigger zoom” (scale 1.5) re-ran it, once.
The example: zoom on hover
The product page uses a tiny plain-JavaScript library, attachZoom(node, options), which enlarges a product photo on hover and returns a function that removes it. It logs attachZoom(scale …) and zoom destroyed. The page also has a quantity button (unrelated state) and a “Bigger zoom” button (changes scale).
1. An inline ref callback
function ZoomCallbackRef({ scale, speed }: ZoomOptions) {
return (
<img
alt="Ceramic Mug"
ref={(node) => {
if (!node) return
return attachZoom(node, { scale, speed })
}}
/>
)
}
Clicking the quantity button twice:
zoom destroyed
attachZoom(scale 1.2)
zoom destroyed
attachZoom(scale 1.2)
The callback is a new function each render, so React detaches the old one (running its cleanup) and attaches the new one. Every unrelated render rebuilt the zoom.
2. useRef + an effect that depends on an object
function ZoomObjectDep({ scale, speed }: ZoomOptions) {
const ref = useRef<HTMLImageElement>(null)
const options = { scale, speed } // a new object on every render
useEffect(() => attachZoom(ref.current!, options), [options])
return <img alt="Ceramic Mug" ref={ref} />
}
zoom destroyed
attachZoom(scale 1.2)
zoom destroyed
attachZoom(scale 1.2)
Same problem, different cause: options is a new object every render, and React compares dependencies by identity:
const a = { scale: 1.2, speed: 300 }
const b = { scale: 1.2, speed: 300 }
log('Object.is(a, b) →', Object.is(a, b), '· Object.is(1.2, 1.2) →', Object.is(1.2, 1.2))
Object.is(a, b) → false · Object.is(1.2, 1.2) → true
3. Depend on the primitives
function ZoomPrimitiveDeps({ scale, speed }: ZoomOptions) {
const ref = useRef<HTMLImageElement>(null)
useEffect(() => attachZoom(ref.current!, { scale, speed }), [scale, speed])
return <img alt="Ceramic Mug" ref={ref} />
}
Two quantity clicks logged nothing at all:
[]
“Bigger zoom” changed scale, so the effect re-ran exactly once:
zoom destroyed
attachZoom(scale 1.5)
The object is now built inside the effect, where its freshness doesn’t matter.
How it works
- Ref objects are the same object for the component’s whole life; changing
.currentdoesn’t re-render. Besides DOM nodes they can hold any value you need between renders without displaying it (a timer id, the previous value). - Ref callbacks run when the node is attached; in React 19 the function they return runs when it’s detached. They’re a good fit for “do something when this node appears”, as long as the callback is stable (defined outside the component or memoized), otherwise they re-run every render, as recorded above.
- Dependencies are compared one by one with
Object.is. Prefer primitive dependencies; if you must depend on an object or function, keep it stable withuseMemo/useCallbackor create it inside the effect. - Don’t silence the lint rule (
react-hooks/exhaustive-deps). A missing dependency is usually a stale-closure bug waiting to happen (React Re-rendering).
Common mistakes
- Reading
ref.currentduring render: it’snullon the first render and doesn’t trigger updates. - Objects, arrays or inline functions in dependency arrays.
- Inline ref callbacks that set up expensive things.
Interview Q&A
Why can't you grab a DOM node directly inside a component?
During render there isn’t one. JSX returns element objects; React creates or updates DOM nodes later, in the commit. The ref prop is how React hands you the node once it exists.
Why did the inline ref callback reset the zoom on unrelated clicks?
A new arrow function is created on every render. React treats a different ref callback as a different ref: it calls the old one’s cleanup and the new one. Recorded: two quantity clicks → two zoom destroyed / attachZoom pairs.
Why does an effect with [options] re-run every render?
Dependencies are compared with Object.is, which compares objects by identity. { scale, speed } built during render is a new object each time. Recorded: Object.is(a, b) → false for two equal objects.
What's the fix?
Depend on the primitive values ([scale, speed]) and build the object inside the effect, or memoize the object. Recorded: no re-runs for unrelated clicks; one re-run when scale changed.
What is a ref besides a DOM handle?
A mutable box that persists across renders and doesn’t cause a re-render when changed, for values the UI doesn’t display.
Related
- Side Effects: effects and cleanup.
- React Re-rendering: identity,
useMemoanduseCallback.
Sources
- react.dev:
useRef, Manipulating the DOM with refs, Ref callbacks, Removing effect dependencies - Dominik Dorfmeister (TkDodo), Avoiding useEffect with callback refs
- Topic order inspired by Kent C. Dodds’ EpicReact React Hooks workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useRef