Sujal
All writing

Published · · 5 min read

Why React StrictMode drew every stroke twice

My whiteboard drew every line twice, but only in development. StrictMode wasn’t the bug. It was showing me an effect that set things up and never cleaned them up.

  • React
  • Debugging
  • StormDraw
StormDraw, the multiplayer whiteboard this post is about
StormDraw, the whiteboard where this happened.
On this page
  1. The bug
  2. What StrictMode actually does
  3. Where my code went wrong
  4. The fix
  5. Why I’m glad it happened
  6. What I’d tell past me

The bug

StormDraw is a multiplayer whiteboard: you draw on a canvas, and every stroke goes over a WebSocket to everyone else in the room. While building it, every line I drew showed up twice. Not offset, exactly on top of itself, which made it easy to miss until undo seemed to do nothing.

The strange part: the deployed version was fine. Only npm run dev drew double.

What StrictMode actually does

In development, React 18’s <StrictMode> mounts every component, unmounts it straight away, and mounts it again. Effects run, their cleanups run, then the effects run a second time. It does this on purpose: a component that breaks when mounted twice will also break when a real user leaves a page and comes back.

MountBoard created, listeners added
UnmountCleanup runs. Mine was missing
Mount againA second board, a second set of listeners
Two boards on one canvas: every stroke is handled twice.

Where my code went wrong

The board was a plain class that attached mouse listeners to the canvas and a message listener to the socket. I created it inside an effect and never tore it down:

Whiteboard.jsxbefore
useEffect(() => {
  const board = new Board(canvasRef.current, socket);
  // Board adds mouse listeners and a socket "message" listener
}, [socket]);

After StrictMode’s mount, unmount and mount, there were two Boards, each with its own mousemove handler and socket listener. One stroke, two draws.

The fix

Give the board a way to undo everything it set up, and return that from the effect:

Whiteboard.jsxafter
useEffect(() => {
  const board = new Board(canvasRef.current, socket);
  return () => board.destroy();
}, [socket]);
Board.js
destroy() {
  this.canvas.removeEventListener('mousedown', this.onDown);
  this.canvas.removeEventListener('mousemove', this.onMove);
  window.removeEventListener('mouseup', this.onUp);
  this.socket.removeEventListener('message', this.onMessage);
}

Why I’m glad it happened

Turning StrictMode off would have “fixed” it too. But the leak was real: without the cleanup, every time someone left a room and joined another, the old board kept listening. StrictMode made it show up on day one instead of in a bug report.

What I’d tell past me

If an effect adds, opens, subscribes to or creates something, it returns a function that removes, closes, unsubscribes from or destroys it.

  • Event listeners → removeEventListener
  • WebSockets and subscriptions → close or unsubscribe
  • Timers → clearInterval / clearTimeout
  • Canvas or chart instances → their own destroy()
Did this help?

Sujal Gupta builds things to understand them, and writes down what broke. Say hi on X or LinkedIn.

Views here are my own, not my employer's.