useRef does three things. Each flavor has its own muscle memory. Conflating them is where bugs live.
Flavor 1 — a handle to a DOM node
You create a ref, hand it to a JSX element via the ref attribute, and after mount the ref's .current property points at the actual DOM node. Read or call DOM methods on it in event handlers and effects. The most common case: focusing an input on mount.
Flavor 2 — render-stable mutable storage
You need a value that survives across renders but doesn't trigger re-renders when it changes. Common uses: caching a timer id between renders, tracking the previous prop value, storing a websocket connection. The shape is identical (useRef(initial)), but you read/write .current directly — no setter, no re-render.
Flavor 3 — callback refs
Sometimes you don't just want the ref's value, you want to react when a node attaches or detaches. Pass a function as the ref instead of a ref object. React calls it with the node on attach, and with null on detach. This is how you wire up an IntersectionObserver, a ResizeObserver, or any third-party library that needs to know about lifecycle.
The distinction that matters
State drives renders. Refs don't. If a value's change should re-render the UI, use state. If a value's change shouldn't re-render but you need to remember it across renders, use a ref. Pick the one that matches what the value's job is.
.current mid-render — DOM refs are null on the first render (the node isn't mounted yet), and reading mutable refs during render is non-deterministic. Always read inside event handlers, effects, or useImperativeHandle.