useRef(initialValue) returns an object of the shape { current: initialValue }, and React 7,897 hands you the same object on every render. The argument is used once, on the first render, and ignored afterwards. That is the whole API. What makes it useful is what it deliberately does not do: React does not track current, so assigning to it never schedules a render. A ref is state the screen does not depend on.
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>
<div id="root"></div><script type="text/babel" data-type="module">
import React, { useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
function Counter() {
const clicks = useRef(0), [renders, setRenders] = useState(0);
return <div style={{font: '16px system-ui'}}>
<button onClick={() => clicks.current++}>Bump the ref</button>{' '}
<button onClick={() => setRenders(r => r + 1)}>Re-render</button>
<p>clicks.current = <b>{clicks.current}</b>, renders = <b>{renders}</b></p></div>;
}
createRoot(document.getElementById('root')).render(<Counter />);
const b = i => document.querySelectorAll('button')[i];
setTimeout(() => { b(0).click(); b(0).click(); b(0).click(); b(1).click(); }, 400);
</script>
The three "Bump the ref" clicks change nothing on screen. Only the fourth, which sets state, produces a render — and that render reads the ref and finally shows 3. Displaying clicks.current breaks a documented rule (never read or write a ref while rendering), and it is broken here to make the timing visible: React never re-reads a ref on its own.
| Question | useState | useRef |
|---|---|---|
| Re-renders on change? | Yes | Never |
| Value inside one render | Frozen | Always the latest |
| Identity across renders | New value | Same object |
| Safe to touch during render? | No | No |
| Typical contents | Anything on screen | Timer IDs, DOM nodes |
One wrinkle: the argument is evaluated on every render even though only the first result is kept, so useRef(new VideoPlayer()) builds a player and throws it away each time. Initialize anything expensive lazily, with if (playerRef.current === null) playerRef.current = new VideoPlayer(); at the top of the component. In Strict Mode, React calls your component twice in development, so each ref is created twice and one copy discarded — harmless as long as current is touched only in handlers and effects.