Often when working with React you’ll need to interact with the DOM directly. You
may need to use a vanilla-js (non-framework-specific) library that needs to
interact directly with the DOM. Often to make a UI accessible you need to
consider focus management requiring you to call .focus()
on an input.
Remember that when you do: <div>hi</div>
that’s actually syntactic sugar for a
React.createElement
so you don’t actually have access to DOM nodes in your
render method. In fact, DOM nodes aren’t created at all until the
ReactDOM.createRoot().render()
method is called. Your component’s render
method is really just responsible for creating and returning React Elements and
has nothing to do with the DOM in particular.
So to get access to the DOM, you need to ask React to give you access to a
particular DOM node when it renders your component. The way this happens is
through a special prop called ref
.
There are two ways to use the ref
prop. A callback and the useRef
hook.
ref callback:
The simplest way is to use the ref
prop is by passing a callback:
function MyDiv() {return (<divref={myDiv => {console.log(`here's my div!`, myDiv)return function cleanup() {console.log(`my div is getting removed from the page!`, myDiv)}}}>Hey, this is my div!</div>)}
This is the preferred approach.
ref object:
For a more complex use case (like you need to interact with the DOM after the
initial render) you can use the useRef
hook.
Here’s a simple example of using the ref
prop with useRef
:
function MyDiv() {const myDivRef = useRef<HTMLDivElement>(null)useEffect(() => {const myDiv = myDivRef.current// myDiv is the div DOM node!console.log(myDiv)}, [])return <div ref={myDivRef}>hi</div>}
The benefit of this API over the ref
callback approach is that you can
store the ref object in a variable and safely access it later within a
useEffect
callback or event handlers.
After the component has been rendered, it’s considered “mounted.” That’s when
the useEffect callback is called and so by that point, the ref should have
its current
property set to the DOM node. So often you’ll do direct DOM
interactions/manipulations in the useEffect
callback.
Every element has a special ref
prop (as shown above). You pass a ref to that
prop and React will give you a reference to the thing that’s created for that
element.
You can also pass ref
to a function component and that component can forward
the ref
onto another element, or it can add handy methods onto it using
useImperativeHandle
which we’ll cover in the “Advanced React APIs” workshop.
📜 Learn more about useRef
from the docs:
https://react.dev/reference/react/useRef
🦉 Note, sometimes the DOM interaction will make observable changes to the UI.
In those cases you’ll want to use useLayoutEffect
and we cover that in the
“Advanced React APIs” workshop.
🦉 A ref is basically state that’s associated to a React component that will not trigger a rerender when changed. So you can store whatever you’d like in a ref, not just DOM nodes.
Keep in mind, React can’t track when you change a ref value. That’s part of it’s appeal in some cases and it can cause trouble in others. You’ll develop the intuition of when to use which over time, but in general it’s best to start with state if you’re unsure and then move to a ref if you decide you don’t want a rerender when it’s updated. We’ll dive deeper into non-DOM refs in future workshops.
👨💼 Our users wanted to be able to control vanilla-tilt
a bit. Some of them
like the speed and glare to look different. So Kellie 🧝♂️ added a form that will
allow them to control those values. This is working great, but something we
noticed is the tilt effect is getting reset whenever you click the count button!
🦉 The reason this happens is because the ref callback is called every time the component is rendered (and the cleanup runs between renders). So we’re re-initializing the tilt effect on every render.
👨💼 This is inefficient and it’s a jarring experience if the user clicks on the corner of the count button. We want the effect to only re-initialize when the options change.
The trick is we want the effect to re-initialize when the vanillaTiltOptions
change, but nothing else. So we can use useEffect
to do that:
useEffect(() => {// set up stuffreturn function cleanup() {// clean up stuff}},// depend on stuff:[],)
🦉 React needs to know when it needs to run your effect callback function again.
We do this using the dependency array which is the second argument to
useEffect
. Whenever values in that array changes, React will call the returned
cleanup function and then invoke the effect callback again.
By default, if you don’t provide a second argument, useEffect
runs after every
render (similar to what we’re currently experiencing with the ref
callback).
While this is probably the right default for correctness, it’s far from optimal
in most useEffect
cases. If you’re not careful, it’s easy to end up with
infinite loops (imagine if you’re calling setState
in the effect which
triggers another render, which calls the effect, which calls setState
and so
on).
👨💼 So what we need to do in this step is move our ref
callback stuff to
useEffect
, create a useRef
so we can access the DOM node in the useEffect
callback, and let React know that our effect callback depends on the
vanillaTiltOptions
the user is providing. Let’s do that by passing the
vanillaTiltOptions
in the dependency array.
👨💼 Our users are annoyed. Whenever they click the incrementing button in the middle, the tilt effect is reset. You can reproduce this more easily by clicking one of the corners of the button.
Moving things into a useEffect
was supposed to help this because we can be
explicit about which dependencies trigger the cleanup
and effect to be run
again. But we’re still having the problem.
If you add a console.log
to the useEffect
, you’ll notice that it runs even
when the button is clicked, even if the actual options are unchanged. The reason
is because the options
object actually did change! This is because the
options
object is a new object every time the component renders. This is
because of the way we’re using the ...
spread operator to collect the options
into a single (brand new) object. This means that the dependency array will
always be different and the effect will always run!
useEffect
iterates through each of our dependencies and checks whether they
have changed and it uses Object.is
to do so (this is effectively the same
as ===
). This means that even if two objects have the same properties, they
will not be considered equal if they are different objects.
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!!
So the easiest way to fix this is by switching from using an object to using the primitive values directly. This way, the dependency array will only change when the actual values change.
So please update the useEffect
to use the primitive values directly. Thanks!
import { useEffect, useRef, useState } from 'react'import { createRoot } from 'react-dom/client'import VanillaTilt from 'vanilla-tilt'interface HTMLVanillaTiltElement extends HTMLDivElement {vanillaTilt?: VanillaTilt}function Tilt({children,max = 25,speed = 400,glare = true,maxGlare = 0.5,}: {children: React.ReactNodemax?: numberspeed?: numberglare?: booleanmaxGlare?: number}) {const tiltRef = useRef<HTMLVanillaTiltElement>(null)useEffect(() => {const { current: tiltNode } = tiltRefif (!tiltNode) returnconst vanillaTiltOptions = {max,speed,glare,'max-glare': maxGlare,}VanillaTilt.init(tiltNode, vanillaTiltOptions)return () => tiltNode.vanillaTilt?.destroy()}, [glare, max, maxGlare, speed])return (<div ref={tiltRef} className="tilt-root"><div className="tilt-child">{children}</div></div>)}function App() {const [showTilt, setShowTilt] = useState(true)const [count, setCount] = useState(0)const [options, setOptions] = useState({max: 25,speed: 400,glare: true,maxGlare: 0.5,})return (<div><button onClick={() => setShowTilt(s => !s)}>Toggle Visibility</button>{showTilt ? (<div className="app"><formonSubmit={e => e.preventDefault()}onChange={event => {const formData = new FormData(event.currentTarget)setOptions({max: Number(formData.get('max')),speed: Number(formData.get('speed')),glare: formData.get('glare') === 'on',maxGlare: Number(formData.get('maxGlare')),})}}><div><label htmlFor="max">Max:</label><input id="max" name="max" type="number" defaultValue={25} /></div><div><label htmlFor="speed">Speed:</label><input id="speed" name="speed" type="number" defaultValue={400} /></div><div><label><input id="glare" name="glare" type="checkbox" defaultChecked />Glare</label></div><div><label htmlFor="maxGlare">Max Glare:</label><inputid="maxGlare"name="maxGlare"type="number"defaultValue={0.5}/></div></form><br /><Tilt {...options}><div className="totally-centered"><buttonclassName="count-button"onClick={() => setCount(c => c + 1)}>{count}</button></div></Tilt></div>) : null}</div>)}const rootEl = document.createElement('div')document.body.append(rootEl)createRoot(rootEl).render(<App />)
Quick Links
Legal Stuff
Social Media