Alright, let’s talk about useRef. This is the hook you reach for when you need to break the rules. React is all about the purity of components, the sanctity of props and state leading to predictable renders. useRef is your backdoor, your escape hatch. It says, “I need to remember something, but I swear, pinky promise, its changing has nothing to do with rendering.” It’s the hook of last resort, and when you need it, you really need it.

Its power, and its danger, comes from one simple fact: a ref is a mutable object that persists for the full lifetime of the component, across all renders, without ever triggering a re-render when you change it. Think of it as a box with a .current property that you can shove anything into.

The Two (Wildly Different) Use Cases

Here’s where people get tripped up. useRef serves two masters, and they are not created equal.

  1. Mutable Instance Variables: This is the core, generic React hook use case. You need to store something that isn’t state: a timer ID, a counter, an instance of an external library, a piece of data you need to compare from the previous render. It’s a class instance variable for your function component.
  2. DOM Refs: This is the specific React attribute use case. You need to get a direct reference to an actual DOM element to measure it, focus it, or let a rogue jQuery plugin savage it.

The hook (useRef()) is the same for both. The application is what differs. Let’s break them down.

useRef as a Mutable Instance Variable

Imagine you need to track how many times a component has rendered, but you don’t want to display that count. Using state would be a disaster—it would cause an infinite loop (update state -> re-render -> update state…). This is a job for useRef.

const RenderCounter = () => {
  const count = useRef(0); // Initialize with 0

  // This happens AFTER the render is committed to the screen
  useEffect(() => {
    count.current = count.current + 1; // Mutating! No re-render!
  });

  return (
    <div>
      <p>I've rendered {count.current} times, but I'll never tell.</p>
    </div>
  );
};

See that? We mutate count.current directly. It’s perfectly safe to do this inside a useEffect or an event handler. The value persists for the next render, but changing it doesn’t schedule a new one. This is the essence of the mutable ref.

Common Pitfall: Do not read or write refs during rendering. The render must be pure and predictable based solely on props and state. If you write count.current during render, subsequent renders would see different values, making your component impossible to reason about. Mutating refs is a side effect, and side effects belong in useEffect or event handlers.

useRef for DOM Access

This is the one you’ll see more often. You create a ref with useRef(null) and then “attach” it to a JSX element using the ref attribute. React will then kindly populate the .current property with the actual DOM element once the component mounts.

const FocusInput = () => {
  const inputRef = useRef(null); // Initialize with null

  const handleClick = () => {
    // TypeScript will know inputRef.current is HTMLInputElement | null
    inputRef.current?.focus(); // Safely focus using optional chaining
  };

  return (
    <div>
      {/* React handles the assignment of the DOM node to inputRef.current */}
      <input type="text" ref={inputRef} />
      <button onClick={handleClick}>Focus the Input</button>
    </div>
  );
};

The mental model here is crucial: the ref={inputRef} is an instruction to React, not an assignment you do yourself. After the component renders and the browser paints, React sets inputRef.current to the DOM node. When the component unmounts, React sets it back to null to prevent memory leaks.

The TypeScript Nuance: Nullability

This is the designers’ “questionable choice” that will bite you constantly. For DOM refs, you must initialize your ref with null: useRef<HTMLInputElement>(null). This means its .current property is always nullable, even after the component mounts.

Why? Because timing. During the initial render, the DOM node doesn’t exist yet, so the ref is null. It’s only after the render is committed that React can populate it. This is why you must always check for null before accessing any properties on a DOM ref.

// BAD: This will crash on first render.
const badWidth = inputRef.current.offsetWidth;

// GOOD: Defensive programming.
const goodWidth = inputRef.current?.offsetWidth; // number | undefined

// Also GOOD: Check in a useEffect, which only runs after mount.
useEffect(() => {
  if (inputRef.current) {
    // In here, TypeScript knows it's not null. Safe to use.
    inputRef.current.focus();
  }
}, []);

It feels tedious, but it’s the correct, safe way to model the actual lifecycle of a DOM node. Embrace the optional chaining (?.) and null checks. They’re your friends.

So there you have it. useRef: your ticket to mutating without guilt and poking the DOM when you absolutely must. Use it sparingly, use it wisely, and always, always watch out for null.