跪拜 Guibai
← Back to the summary

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

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

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

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

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

Why This Design


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

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.