React|Built-in Hook Mastery

useRef: Beyond DOM Access—Using It as a Variable Store Without Re-renders

6
useRef: Beyond DOM Access—Using It as a Variable Store Without Re-renders

Chris is building a 'Stopwatch' feature this time.

When the "Start" button is clicked, time flows second by second. When "Stop" is clicked, it pauses.

Accustomed to React, Chris naturally reached for useState.

"Time is a changing value, and I need to remember the setInterval ID to stop the timer. I should make both of them State, right?"

typescript
// ❌ Chris's Inefficient Code
function Stopwatch() {
  const [time, setTime] = useState(0);
  const [timerId, setTimerId] = useState<number | null>(null);

  const start = () => {
    // Saving the timer ID to state -> Triggers a re-render!
    const id = window.setInterval(() => {
      setTime(t => t + 1);
    }, 1000);
    setTimerId(id);
  };

  const stop = () => {
    if (timerId) clearInterval(timerId);
  };

  return (
    <div>
      <span>{time}s</span>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

The feature works. However, there is a problem. The moment timerId is set (setTimerId), the component re-renders even though there is absolutely no visual change on the screen.

He simply wanted to store some data, but React makes a fuss saying, "The state changed!" and repaints the screen.

The secret weapon used when you "want to persist a value but don't want to trigger a render" is useRef.

1. useRef: A Pocket That Doesn't Trigger Re-renders

We are usually taught to use useRef when selecting a specific DOM element instead of document.getElementById (e.g., inputRef.current.focus()). However, the true power of useRef lies in the fact that it is a "storage that persists for the component's lifecycle, irrelevant to rendering."

useRef returns a simple JavaScript object in the form of { current: ... }. We can put anything into this current property: numbers, strings, objects, or even functions.

The most important feature is that React does not re-render the component even if the current value changes.

Solution: Storing Timer ID in Ref

Let's optimize Chris's code using useRef.

typescript
// ✅ Optimized Code
function Stopwatch() {
  const [time, setTime] = useState(0);
  // Store values irrelevant to rendering in a ref.
  const timerIdRef = useRef<number | null>(null);

  const start = () => {
    // Ignore if already running
    if (timerIdRef.current) return;

    timerIdRef.current = window.setInterval(() => {
      setTime(t => t + 1);
    }, 1000);
    // Assigning a value to timerIdRef.current does NOT trigger a re-render.
  };

  const stop = () => {
    if (timerIdRef.current) {
      clearInterval(timerIdRef.current);
      timerIdRef.current = null;
    }
  };

  // ...
}

Now, saving or clearing the timer ID does not cause unnecessary rendering. The screen updates only when the time actually changes (setTime).

2. Variable (let) vs. State vs. Ref: What Should I Use?

There is a common question beginners ask:

"Can't I just declare a variable like let timerId = 0 inside the component?"

No, you can't. The reason lies in how React's rendering works.

  • Local Variable (let): Every time the component re-renders, the function runs again, and the variable is re-initialized. The timer ID would be lost. (Conversely, if you declare it as a global variable outside the component, a bug arises where all components share the same ID.)
  • State (useState): The value persists, but it repaints the screen every time the value changes. Suitable for data directly shown in the UI.
  • Ref (useRef): The value persists, and changing it does not repaint the screen. Suitable for storing data irrelevant to the UI.
  • 3. Advanced Use: Remembering Previous Values

    Another excellent use case for useRef is "remembering the value from the previous render."

    For example, to know if a stock price "rose" or "fell," you need to compare the previous price with the current price.

    If you update the ref inside useEffect, you can save the value at a point after the rendering.

    typescript
    function PriceDisplay({ price }: { price: number }) {
      const prevPriceRef = useRef<number>(price);
      
      useEffect(() => {
        // After rendering finishes, save the current price as the 'previous price'.
        // This is for use in the NEXT render.
        prevPriceRef.current = price;
      }, [price]);
    
      const prevPrice = prevPriceRef.current;
      const direction = price > prevPrice ? 'Up 🔺' : 'Down 🔻';
    
      return <div>{price}KRW ({direction})</div>;
    }

    This pattern perfectly replaces the functionality of the componentDidUpdate(prevProps) lifecycle method, which functional components lack.

    4. Caution: Do Not Read During Rendering

    There is an iron rule you must follow when using useRef.

    "Do not read or write ref.current inside rendering logic (the return statement or the component body)."

    typescript
    function BadComponent() {
      const count = useRef(0)
      
      // ❌ Forbidden: Rendering results become unpredictable.
      count.current = count.current + 1; 
      
      // ❌ Forbidden: Do not display ref values directly on the screen.
      // (Since the screen doesn't update when the value changes, the user sees stale data.)
      return <div>{count.current}</div>;
    }

    Modifying and reading refs must only happen inside Event Handlers or useEffect. If something needs to be drawn on the screen, that is the job of useState, not useRef.

    Key Takeaways

  • Role: useRef isn't just a tool for grabbing the DOM. It is a storage used "when you want to persist a value without re-rendering."
  • Comparison:
  • Use Cases: Essential for Timer IDs (setTimeout), tracking previous values (prevProps), storing external library instances, managing animation frames, etc.
  • Caution: Do not touch ref.current during rendering (e.g., in the return statement).

  • We have mastered how to handle variables. Next, it's time to handle relationships between components.

    What if a parent wants to focus an input inside a child component? We used to use forwardRef for this task, which was impossible with props alone, but this syntax is changing completely in React 19.

    Continuing in: “The Retirement of forwardRef and useImperativeHandle: Now, ref is Just a Prop”

    🔗 References

  • React Docs - Referencing Values with Refs
  • React Docs - useRef
  • Comments (0)

    0/1000 characters
    Loading comments...