Lindsay Edwards

State a render loop reads belongs in a ref

On this page

I built a small canvas game and could not lose it. Not because I was good. Because the game refused to let me. The death screen would flash for a single frame and then, quietly, we were playing again.

Several of these games had the exact same bug. Once you see it once, you see it everywhere.

The self-erasing chain#

The animation ran on requestAnimationFrame. The setup lived in an effect, and the game-over state was in that effect’s dependency array. The effect body, on running, called reset().

Read that back slowly, because the loop is right there.

You die. Game-over becomes true. Because game-over is a dependency, the effect re-runs. The effect body calls reset(). Reset clears game-over. Game-over changing re-runs the effect again, and now you are alive, mid-game, with no memory of dying.

The death screen did render. For one frame. Then the very state that triggered it erased itself and took the screen with it. A perfectly circular bug that made the game unlosable and looked, from the outside, like nothing was wrong at all.

A game you cannot lose is not a game, it is a broken toy. A player would feel that within seconds, even though every log and every test looked perfectly clean.

Put it in a ref#

The fix is a rule I now reach for without thinking: state that a rendering loop reads every frame usually belongs in a ref, not in the effect’s dependency array.

So game-over gets mirrored into a ref. The animation loop and the input handlers read the ref. The effect’s dependency array goes minimal, so setting game-over no longer re-runs setup and no longer triggers a reset.

Order matters too. I set the ref before I set the state. The ref is what the loop checks, so setting it first halts the loop on the same frame the player dies, instead of one frame later. The state update is still there for React to re-render the UI. The ref is there so the loop stops instantly.

State a rendering loop reads every frame belongs in a ref. Deps that re-run teardown and setup create invisible, self-resetting loops.

The StrictMode cousin#

A related one bit me in the same codebase. Under React’s StrictMode, effects and updaters get double-invoked in development to flush out impure code.

I had tucked a side effect inside a setState updater. Something like incrementing a score and, in the same updater, playing a sound or writing to a counter. StrictMode ran the updater twice. The side effect fired twice. The sound stuttered.

The rule that falls out: compute the next state purely, and run the side effect once, in the handler, outside the updater.

// updater computes state and nothing else
setScore(prev => prev + 1)
// the side effect lives here, once
playSound()

An updater’s only job is to turn the previous state into the next state. The moment it also does something to the outside world, double-rendering will do that something twice.

Two habits came out of this.

First, if a requestAnimationFrame loop reads a value every frame, keep that value in a ref and keep your effect dependencies lean. Dependencies that tear down and rebuild the loop can quietly reset the very thing you were trying to hold onto.

Second, keep state updaters pure. Side effects go in the handler, run once. When double-invoke is in play, anything hiding inside an updater happens twice, and you will spend an afternoon wondering why.

The game where you cannot lose is funny. It is less funny when it ships.

Keep reading