React|Custom Hooks

useInterval & useTimeout: Escaping React's Closure Trap

5
useInterval & useTimeout: Escaping React's Closure Trap

Chris is building a simple 'Countdown Timer'.

It’s a feature where the number decreases by 1 every second. Having moved past his beginner phase in React, Chris confidently combined useEffect and setInterval.

typescript
// ❌ Chris's Frozen Timer
function Timer() {
  const [count, setCount] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // Debugging
      setCount(count - 1);
    }, 1000);

    return () => clearInterval(id);
  }, []); // Run only once on mount

  return <h1>Time Left: {count}</h1>;
}

Contrary to expectations, the number on the screen changed to 9 once and then froze forever. Checking the console, he saw 10, 10, 10... printing infinitely.

This is the infamous "Stale Closure" problem that every React developer faces at least once.

Today, we will understand where the characteristics of JavaScript closures collide with the React lifecycle, and build a useInterval hook that solves this elegantly using useRef.

1. The Cause: A Space Where Time Stopped

Why doesn't count decrease?

It is because the dependency array (deps) of useEffect is empty [].

  • The component renders for the first time (count = 10).
  • useEffect runs, and setInterval is registered.
  • The registered callback function () => setCount(count - 1) remembers the environment (Scope) at the moment of its creation (Closure).
  • In this environment, count is eternally 10.
  • Even after 1 second or 10 seconds, the timer only sets 10 - 1, which is 9.
  • A Wrong Solution: Adding to the Dependency Array

    "Then can't we just add [count] to the dependencies?"

    typescript
    useEffect(() => {
      const id = setInterval(...);
      return () => clearInterval(id);
    }, [count]); // Re-run whenever count changes

    It works. However, this means clearing and re-registering the timer every second.

    Not only does the timer's precision drop, but if the interval duration is short, performance issues can arise. We want to "keep the timer running, but let the internal logic know the latest state."

    2. The Solution: Capturing the Latest State with useRef

    The pattern proposed by Dan Abramov from the React team to solve this is useInterval.

    The core idea is to "Separate what changes (Callback) from what doesn't change (Interval)."

  • setInterval should be executed only once.
  • However, the callback function executed inside it needs to know the latest state every time.
  • useRef does not trigger re-renders when its value changes, and we can retrieve the latest value via .current at any time.
  • Implementing useInterval

    typescript
    // useInterval.ts
    import { useEffect, useRef } from 'react';
    
    export function useInterval(callback: () => void, delay: number | null) {
      // 1. Create a ref to store the latest callback
      const savedCallback = useRef(callback);
    
      // 2. Update the ref with the latest callback whenever re-rendered
      // (This process does not affect rendering)
      useEffect(() => {
        savedCallback.current = callback;
      }, [callback]);
    
      // 3. Set up the interval
      useEffect(() => {
        // If delay is null, stop the timer (Declarative pause feature)
        if (delay === null) return;
    
        const tick = () => {
          // The timer always executes 'ref.current'.
          // The ref always holds the latest callback!
          savedCallback.current();
        };
    
        const id = setInterval(tick, delay);
        return () => clearInterval(id);
      }, [delay]); // Reset timer only when delay changes
    }

    This code is a kind of magic. setInterval keeps calling the function tick, but tick internally refers to savedCallback.current. We just secretly swap savedCallback.current at render time.

    3. Practical Application: Chris's Timer Resurrected

    Now Chris can write code intuitively without worrying about complex useEffect and closures.

    typescript
    // Timer.tsx
    import { useState } from 'react';
    import { useInterval } from './useInterval';
    
    function Timer() {
      const [count, setCount] = useState(10);
      const [isPlaying, setIsPlaying] = useState(true);
    
      // βœ… Feels like using setInterval declaratively!
      useInterval(() => {
        setCount(count - 1);
      }, isPlaying ? 1000 : null); // Passing null pauses it.
    
      return (
        <div>
          <h1>{count}</h1>
          <button onClick={() => setIsPlaying(!isPlaying)}>
            {isPlaying ? 'Pause' : 'Resume'}
          </button>
        </div>
      );
    }

    As a bonus, he easily implemented a pause feature by passing null to delay. The code has become much more readable.

    4. The Sibling: useTimeout

    Using the same principle, useTimeout, which turns setTimeout into a hook, is also useful.

    It is handy when something needs to execute exactly once after a delay, but you need to cancel it or change the time if conditions change in the meantime.

    typescript
    // useTimeout.ts
    import { useEffect, useRef } from 'react';
    
    export function useTimeout(callback: () => void, delay: number | null) {
      const savedCallback = useRef(callback);
    
      useEffect(() => {
        savedCallback.current = callback;
      }, [callback]);
    
      useEffect(() => {
        if (delay === null) return;
        const id = setTimeout(() => savedCallback.current(), delay);
        return () => clearTimeout(id);
      }, [delay]);
    }

    Key Takeaways

  • Problem: Using setInterval inside useEffect causes a bug where it only references the initial state value due to Stale Closures.
  • Solution: Use useRef to store the "latest callback function" and make the timer always call ref.current.
  • useInterval: A custom hook that allows managing intervals in a declarative way. You can easily stop the timer by setting delay to null.
  • Lesson: When React's render flow and JavaScript's async time flow are out of sync, Ref becomes the bridge connecting them.

  • We have mastered handling time. Now it's time to handle space (the screen).

    "I want to hide the sidebar on mobile screens."

    CSS media queries (@media) alone cannot control JavaScript logic (conditional rendering). Let's create a hook that detects screen size changes in real-time within JS.

    Continuing in: "useMediaQuery & useWindowSize: Controlling Responsive Design with JavaScript."

    πŸ”— References

  • Dan Abramov - Making setInterval Declarative
  • usehooks-ts: useInterval
  • Comments (0)

    0/1000 characters
    Loading comments...