~/webline_global $

// Everyday tech, explained simply.

Why Your Refs Break After React 18 Strict Mode Double-Renders

· 6 min read
Why Your Refs Break After React 18 Strict Mode Double-Renders

I remember the exact moment my React app started acting haunted. I was working on a multiplayer game lobby—a real-time WebSocket system that tracked player readiness and seat assignments. Everything worked fine in development until I added StrictMode. Suddenly, my useRef-based WebSocket connections were cloning themselves, my current values were resetting mid-render, and the game lobby was showing phantom players. The console was full of duplicate connection logs. It took me two days to realize the culprit wasn't my WebSocket logic—it was React 18’s deliberate double-rendering in development mode, and my refs were entirely unprepared for it.

Why React 18 Double-Renders Your Components

React 18 introduced a subtle but powerful change to StrictMode: in development builds, components now mount, unmount, and remount in a rapid cycle. This isn't a bug. It's a feature designed to surface side-effect bugs before they hit production.

The React team calls this "strict effects." When you wrap your app in <StrictMode>, React intentionally runs your component's setup and cleanup functions twice during the initial mount. The goal is to catch code that doesn't properly handle unmounting or that leaks resources. If your effect doesn't clean up correctly, you'll see duplicate console logs, double API calls, or—in my case—multiple WebSocket connections piling up.

This double-invocation only happens in development. Production builds run normally. But here's the trap: your refs are mutable objects that persist across renders. They're supposed to survive re-renders, but they don't survive the unmount-remount cycle that StrictMode performs. When React unmounts your component, it clears the ref's current value. When it remounts, your ref resets to its initial state. If you're not expecting that reset, your application state collapses.

The Mechanism Behind the Madness

React's useRef hook returns a mutable object with a .current property. Unlike state, changing .current doesn't trigger a re-render. This makes refs ideal for storing DOM references, timer IDs, or WebSocket instances. But the ref object itself is created once per component lifecycle.

In React 18's strict mode, the component lifecycle includes an intentional teardown. When the component unmounts, React calls your effect's cleanup function. Then it remounts the component, which re-runs your effect. During this remount, React creates a fresh ref object. Your old ref—with its accumulated state—is garbage collected. If you had stored a WebSocket connection in that ref, it's gone. The new ref starts with whatever initial value you provided.

This is why you see duplicate connections: your first effect runs, creates a WebSocket, stores it in the ref. React unmounts, kills the ref. React remounts, your effect runs again, creates a second WebSocket, stores it in the new ref. The first WebSocket is orphaned—still open, but unreachable. You now have a zombie connection.

The Three Ways Your Refs Break

Refs break in predictable patterns under double-rendering. Once you know these patterns, you can spot them in your own code.

Pattern One: Resource Leaks

The most common breakage happens when you store a resource in a ref without cleaning up the previous instance. WebSocket connections, setInterval IDs, and AbortController instances are classic victims.

const wsRef = useRef(null);

useEffect(() => {
  wsRef.current = new WebSocket('wss://api.example.com/game');
  // No cleanup function
}, []);

Under strict mode, this creates two WebSocket connections. The first one hangs open forever. Your server sees duplicate heartbeats. If you're running a real-time game lobby, players appear to connect twice, and you might accidentally assign them to two seats.

Pattern Two: Stale Closure Data

Refs are often used to hold mutable data that effects need to read without causing re-renders. When strict mode double-renders, your effect might capture a stale snapshot of state or props because the ref was reset.

const playerCountRef = useRef(0);

useEffect(() => {
  playerCountRef.current = props.playerCount;
  // Some logic that uses playerCountRef.current
}, [props.playerCount]);

After the unmount-remount cycle, playerCountRef.current resets to 0. If your effect depends on reading that value before the state update propagates, you'll get a brief flash of incorrect data. In a payment integration flow, that could mean showing a zero-balance to a user who just deposited $500.

Pattern Three: DOM Reference Confusion

If you're using refs to reference DOM elements—for focus management, animation targets, or canvas contexts—strict mode can cause your refs to point to detached DOM nodes.

const canvasRef = useRef(null);

useEffect(() => {
  const ctx = canvasRef.current.getContext('2d');
  // Start rendering loop
}, []);

React unmounts the component, which removes the canvas from the DOM. Your ref still holds a reference to the old canvas element. When React remounts, a new canvas element is created, and your ref now points to the old, detached one. Any getContext calls on that ref will fail silently, and your canvas stays blank.

How to Fix Your Refs for Strict Mode

The fix isn't to disable strict mode. That's like turning off your smoke detector because it keeps beeping when you burn toast. The fix is to write your effects correctly.

Always Return a Cleanup Function

Every useEffect that sets a ref value should return a cleanup function that nullifies or closes the resource.

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/game');
  wsRef.current = ws;

  return () => {
    ws.close();
    wsRef.current = null;
  };
}, []);

Now when React unmounts, the cleanup runs and closes the WebSocket. When React remounts, a fresh connection is created. No zombies. This pattern works for timers, observers, and any other resource.

Use Refs for Side Effects, Not State

Refs are great for storing values that don't affect the UI. But if you find yourself reading from a ref to compute render output, you're probably abusing them. Move that logic to state or a derived value.

// Bad: reading a ref during render
const count = countRef.current;
return <div>{count}</div>;

// Good: use state for render values
const [count, setCount] = useState(0);

Refs that are only written to and read inside effects are naturally safer under double-rendering because the cleanup function resets them.

Initialize Refs Lazily

If you need a complex initial value for a ref—like a class instance or a configuration object—initialize it lazily inside a useState or useMemo instead of in the ref's initializer.

// Risky: initializer runs twice under strict mode
const gameEngineRef = useRef(new GameEngine());

// Safe: create once, store in ref
const [gameEngine] = useState(() => new GameEngine());
const gameEngineRef = useRef(gameEngine);

The useState initializer only runs once per component mount. Even under strict mode's double-render, the state value persists through the unmount-remount cycle because React preserves state across the teardown.

Real-World Example: Fixing a WebSocket Lobby

Let me walk you through how I fixed that haunted game lobby. The original code looked like this:

function GameLobby({ gameId }) {
  const wsRef = useRef(null);
  const [players, setPlayers] = useState([]);

  useEffect(() => {
    wsRef.current = new WebSocket(`wss://api.example.com/lobby/${gameId}`);
    wsRef.current.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === 'player_join') {
        setPlayers(prev => [...prev, data.player]);
      }
    };
  }, [gameId]);
}

Under strict mode, this created two WebSockets. The first one's onmessage handler was attached to the old socket, which was still open. When a player joined, both sockets fired the handler, adding the player twice to the state. The lobby showed duplicate names.

The fix was straightforward:

function GameLobby({ gameId }) {
  const wsRef = useRef(null);
  const [players, setPlayers] = useState([]);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/lobby/${gameId}`);
    wsRef.current = ws;

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === 'player_join') {
        setPlayers(prev => [...prev, data.player]);
      }
    };

    return () => {
      ws.close();
      wsRef.current = null;
    };
  }, [gameId]);
}

The cleanup function closes the WebSocket and nullifies the ref. When strict mode remounts, a new socket connects cleanly. No duplicates. The lobby works in both development and production.

Forward-Looking Note

Strict mode's double-rendering isn't going away. If anything, future versions of React will lean harder into this pattern to enforce clean effect hygiene. The React team is building toward concurrent features and server components where side-effect management becomes even more critical. Your refs need to be resilient to unmount-remount cycles not just for development convenience, but because production code will eventually run in environments where components mount and unmount unpredictably—think streaming server rendering or progressive hydration.

The practical takeaway here isn't just about fixing bugs today. It's about adopting a mental model where every effect you write has an explicit teardown path. Treat your refs like rental cars: you always return them in the same condition you found them. When you build that habit, strict mode stops being an annoyance and becomes your best debugging tool. Your WebSocket connections stay clean, your payment flows remain accurate, and your real-time systems handle concurrent users without phantom duplicates. That's the kind of foundation you need when you're scaling from a demo to a production system handling thousands of simultaneous connections.