useRef Is React's Imperative Escape Hatch for DOM and Persistent Values
Today's Focus
useRef is the only backdoor React leaves for imperative programming. When useState can't handle something (touching the DOM, storing variables that shouldn't trigger a render, holding a Worker), useRef is the tool. The core idea is one thing: it returns a { current: xxx } object, and modifying .current does not trigger a re-render.
The Pain of DOM Programming
Key Points
Before React, front-end development relied on document.querySelector for manual DOM manipulation. JS runs in the V8 engine, and the DOM lives in the rendering engine; every operation requires cross-engine communication, which is expensive. React's solution: you just manage the data (useState), and the framework handles all DOM updates.
Code
// The obsolete way: imperative DOM programming
const h1 = document.querySelector('h1')
h1.innerText = 'Hello' // Cross-engine communication
h1.style.color = 'red' // Another cross-engine call
// The React way: declarative
const [msg, setMsg] = useState('Hello')
// <h1>{msg}</h1>
setMsg('world') // Only change the data; React calculates the diff and batch-updates the DOM
Breakdown
- Imperative: You command the browser step-by-step, and data is scattered across DOM nodes.
- Declarative: You simply declare "the value changed," and React takes over all DOM updates.
Why This Design
Cross-engine communication is a front-end performance bottleneck. React consolidates DOM operations within the framework layer, optimizing with batching and diffing, so developers no longer touch the DOM directly. But there are always tasks that require touching the DOM (focus, playback, scrolling), and that's when useRef is used.
useRef + DOM
Key Points
Binding a DOM node with useRef involves three steps: create a container with useRef(null) → bind it to a tag with ref={} → access the DOM via .current inside useEffect.
Code
ref-focus-demo/src/App.jsx:9,16,22
const inputRef = useRef(null) // ① Create an empty container
useEffect(() => {
inputRef.current.focus() // ③ After mounting, the DOM is ready; call focus
}, [])
<input ref={inputRef} /> // ② Bind to the tag
Breakdown
useRef(null)returns{ current: null }. The initial value is null because the input hasn't rendered yet.ref={inputRef}is a special React syntax. After rendering is complete, it automatically places the real DOM node into.current.useEffect([], [])executes after mounting. At this point,.currentis the real DOM node, so calling.focus()works.
Why This Design
React components are functions, and the DOM doesn't exist during rendering. You must wait until rendering is finished and the DOM is attached before operating on it. The empty dependency array in useEffect executes precisely at this point in time.
Storing Values Without Re-rendering
Key Points
useRef can store not only DOM nodes but also any value. When .current is modified, the value does change, but React doesn't know about it, so the page doesn't update. You need to borrow useState to manually trigger a re-render.
Code
ref-focus-demo/src/App.jsx:37-43
const numRef = useRef(0) // Initial value 0
const [, forceRender] = useState(0) // Only borrow the setter, don't read the value
console.log(numRef.current) // Prints on every render
<div onClick={() => {
numRef.current += 1 // Change the value; React is unaware
forceRender() // Manually trigger a re-render
}}>
{numRef.current} {/* Reads the latest value only during rendering */}
</div>
Breakdown
numRef.current += 1: The value changes from 0 to 1, 2, 3. You can see it in the console, but the page doesn't move.forceRender(): Borrows useState's rendering capability to force React to re-execute the function.- Function re-executes →
{numRef.current}is re-read → displays the latest value.
Why This Design
Some variables don't need to be displayed on the page but must persist across renders (timer IDs, previous values, Worker references). Using useState would cause a re-render on every change, wasting performance. useRef is perfect for this.
Not Reactive
Key Points
useState is reactive (changing it automatically triggers a re-render); useRef is not (changing it does nothing). This is the most fundamental difference between the two.
Code
// useState: Reactive
const [count, setCount] = useState(0)
// setCount(1) → Automatic re-render, page updates
// useRef: Non-reactive
const countRef = useRef(0)
// countRef.current = 1 → Value changes, page doesn't move
Breakdown
- useState's setter notifies React that "the value changed, re-render."
- useRef's
.currentis just a property on a plain object. React has no idea when it's changed. - Store DOM nodes, Workers, and timer IDs with ref; display values on the page with state.
Why This Design
The two Hooks have distinct responsibilities: state manages the foreground (what the user sees), and ref manages the background (pure storage). If ref were also reactive, there would be no way to store "variables you don't want to trigger a re-render."
Worker ref
Key Points
JavaScript is single-threaded. Complex calculations (large loops, LLMs, game logic) block the main thread, freezing the page. A Web Worker opens another thread; you offload heavy work to it, and the main thread continues responding to user interactions. useRef is used to hold the Worker instance.
Code
ref-worker-demo/src/App.jsx:20-27
const workerRef = useRef(null)
useEffect(() => {
workerRef.current = new Worker(
new URL('./worker.js', import.meta.url)
)
}, [])
ref-worker-demo/src/worker.js:1
console.log('Worker thread started, I am here')
Breakdown
workerRef.current = new Worker(...): Manually stores the Worker instance in the ref.new URL('./worker.js', import.meta.url): Vite's modular Worker loading method.- Placed inside useEffect: avoids creation during rendering (gets out of the way), and the empty dependency array ensures it's created only once.
Why This Design
- The Worker object doesn't need to be displayed, so use a ref instead of state.
- Don't call
new Worker()at the top level of the function body: it would create a new instance on every render, causing a memory leak. - Place it in useEffect with an empty dependency array: creates once after mounting, and destroys it with
terminate()on unmount.
The .current Design
Key Points
Why doesn't useRef return the value directly? Why wrap it in .current? Because in JavaScript, only objects are reference types; primitive values are reset when a function re-executes.
Code
// If it didn't wrap it in an object:
function App() {
let num = 0 // Re-declared on every render, always 0
num += 1 // Changes from 0 to 1, but resets to 0 on the next render
}
// useRef's approach:
const numRef = useRef(0) // { current: 0 }
// The object's address doesn't change → modifying .current doesn't lose it → persistence
Breakdown
- A React component's function re-executes on every render, resetting all ordinary variables.
- Objects are reference types; as long as the address doesn't change, modifying a property is persistent.
- The
{ current: 0 }container is always the same object; only the value inside it changes.
Why This Design
It's not that React deliberately made it complex; it's a limitation of the JavaScript language itself. Returning an object is the only way to make a value persist across renders.