🧠 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
myDivparameter 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.