🧠 Understanding DOM Side-Effects and ref
in React
In React, your job is to describe what the UI should look like—not how it should be rendered. But what happens when you do need to work with the DOM directly? Whether you’re integrating with a third-party JavaScript library or managing focus manually, React gives you tools to perform side-effects while respecting its rendering model.
In this article, we’ll explore DOM side-effects, how to use ref
and useEffect
properly, and how to avoid some common pitfalls around dependencies. We’ll even build a fun interactive component using vanilla-tilt
. 🎉
Let’s start with a basic principle: React’s render methods do not create DOM nodes. Instead, they return React Elements—which are like blueprints. Only when ReactDOM.createRoot().render(...)
is called do those blueprints get turned into actual DOM nodes.
This means:
return <div>Hi</div>
…doesn’t give you access to the <div>
DOM node—only a React Element object.
ref
: Getting Access to the DOMReact provides a special prop called ref
that lets you access the underlying DOM node once it exists.
There are two main ways to use ref
:
function MyDiv() {return (<divref={(myDiv) => {if (!myDiv) returnconsole.log('Here’s my div!', myDiv)}}>Hey, this is my div!</div>)}
🔍 This ref is a function that gets called after the DOM element is mounted or updated.
⚠️ With TypeScript, the
myDiv
parameter may benull
, so you should guard against that.
useRef
HookFor more advanced or reusable DOM interactions, you can use the useRef
hook:
import { useRef, useEffect } from 'react'function MyDiv() {const myDivRef = useRef<HTMLDivElement>(null)useEffect(() => {if (myDivRef.current) {console.log('My DOM node:', myDivRef.current)}}, [])return <div ref={myDivRef}>hi</div>}
This lets you persist a reference to a DOM node across re-renders without triggering additional renders.
Let’s say you’re building a <Tilt />
component using vanilla-tilt
. That library expects a real DOM node and attaches event listeners to it. Since React owns the node creation, we need to ask React to give it to us using a ref.
function Tilt() {const tiltRef = useRef(null)useEffect(() => {const tiltNode = tiltRef.currentVanillaTilt.init(tiltNode, {max: 25,speed: 400,glare: true,"max-glare": 0.5,})return () => {tiltNode.vanillaTilt?.destroy()}}, [])return <div ref={tiltRef} className="tilt-root">Fancy Tilt ✨</div>}
Let’s say you enhance your component with user controls so people can change tilt options like speed and glare. You now pass those options into the component, and update the effect like this:
const options1 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 }const options2 = { glare: true, max: 25, 'max-glare': 0.5, speed: 400 }Object.is(options1, options2) // false!!
useEffect(() => {const tiltNode = tiltRef.currentVanillaTilt.init(tiltNode, options)return () => tiltNode.vanillaTilt?.destroy()}, [options])
This seems correct—but the tilt effect resets every time you click the button—even if the options didn’t actually change. Why?
The problem is subtle: the options
object is recreated every time the component renders. So even if its values are the same, it’s a different object in memory.
const a = { speed: 400 }const b = { speed: 400 }console.log(Object.is(a, b)) // false
Since useEffect
checks dependencies using Object.is
, it treats options
as “changed” every render—even if values are unchanged. That’s why the effect keeps running.
Instead of passing a full object into your useEffect
dependency array, pull out the primitive values directly:
useEffect(() => {const tiltNode = tiltRef.currentVanillaTilt.init(tiltNode, {glare,max,speed,'max-glare': maxGlare,})return () => tiltNode.vanillaTilt?.destroy()}, [glare, max, speed, maxGlare])
This ensures the effect only reruns when actual values change—not just when object references change.
Refs are often used for DOM access, but they’re just persistent containers:
const myRef = useRef(initialValue)
myRef.current
holds any value you wantmyRef.current
does not trigger a re-renderThis makes refs a good fit for storing mutable state that doesn’t affect the visual output—like timers, previous props, or external library instances.
React’s declarative nature doesn’t stop you from performing imperative tasks—you just need to do them the React way. Here’s what we covered:
✅ Use ref
to access the DOM.
✅ Use useEffect
to safely perform setup and cleanup side-effects.
✅ Use primitive dependencies in useEffect
to avoid unnecessary reruns.
✅ Avoid updating DOM or initializing effects inside render methods.
Ready to tilt your way into DOM mastery? Give it a try! 🧙♂️✨
If you found this helpful, share it with someone learning React, or follow along for deeper dives into React’s more advanced hooks and behaviors.