Must knowHooks · 05

DOM Refs and Effect Dependencies

useRef and ref callbacks for real DOM nodes, and why object dependencies make effects re-run.

On this page · 7 sections
  1. In one minute
  2. The example: zoom on hover
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

[options] (object)
ProductPhoto.tsx
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}
What you see
Quantity: 1
Zoom on hover: attached (scale 1.2)
Console after two Quantity clicks (recorded)
click 1: zoom destroyed
click 1: attachZoom(scale 1.2)
click 2: zoom destroyed
click 2: attachZoom(scale 1.2)
  1. eventClick “Quantity”. Unrelated state changes, so the product re-renders.
  2. render phaseconst options = { scale, speed } // new every renderThe render creates a new options object with the same contents.
  3. commit phase[options],React compares each dependency with Object.is, and objects compare by identity. Recorded: Object.is(a, b) → false for two equal objects.
  4. effects() => attachZoom(ref.current, options),So React tears the zoom down and sets it up again, on every click.

0 / 0
A product photo zooms on hover through a small non-React library. Clicking “Quantity” (unrelated state) shows how effect dependencies are compared (both recorded).

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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react/useRef