Twelve lint errors, one file, two mistakes. The first one had been shipping a bug I'd been squinting at for an hour without naming it: the sky over the burning ridge twitched. Not drifted, not animated. Twitched. Every so often all 320 embers jumped to a completely new arrangement, and the 110 lights of the Reno valley behind the camera jumped with them.
Both fields were scattered with Math.random() inside a useMemo. That is not a rendering bug. That is React doing exactly what it says it does, to code that assumed otherwise.

GO NOW. Everything glowing in that frame is procedural, which means everything glowing in that frame is a scatter with a seed problem waiting to happen.
The rule is not "memoize the scatter," it's "render is not where random lives"
I reduced it to the smallest file that reproduces both errors, because I wanted the rule names and not my guesses about them:
'use client'
import { useMemo } from 'react'
import * as THREE from 'three'
const mat = new THREE.MeshStandardMaterial()
export function Probe() {
const geo = useMemo(() => {
const pos = new Float32Array(30)
for (let i = 0; i < 10; i++) pos[i * 3] = Math.random() * 10
return pos
}, [])
const m = useMemo(() => new THREE.MeshStandardMaterial(), [])
m.emissiveIntensity = 2
mat.emissiveIntensity = 1
return <mesh material={m}>{geo.length}</mesh>
}
npx eslint on that gives three errors from two rules. The first:
10:47 error Error: Cannot call impure function during render
`Math.random` is an impure function. Calling an impure function can produce
unstable results that update unpredictably when the component happens to
re-render. react-hooks/purity
"When the component happens to re-render" is the entire problem, and it is worth sitting with. My instinct was that useMemo with [] means the factory runs once, so Math.random() inside it is fine. It isn't. useMemo is a performance hint, not a guarantee. React documents that it may throw a cached value away when it has a reason to: it drops the cache in development every time the component's file is edited, and it drops the cache in development and production if the component suspends during initial mount. Strict Mode goes further and calls the calculation function twice on purpose, specifically to surface impure ones. An empty dependency array says "nothing I depend on has changed." It does not say "this will never run again."
So the memo factory has to be a pure function of its dependencies. Math.random() has no dependencies and returns something different every call, which makes the factory a function of wall-clock luck. Every time React decided to recompute, my whole ember field moved.
The fix was already in the file, three functions up
The distant ridges in this scene are extruded silhouettes generated from a seed, and they were already using a small Lehmer generator, which I had written earlier the same night and then completely failed to reach for when I added the embers:
// Lehmer PRNG: scatter built during render must be deterministic. Math.random
// would resample on every re-render (and violates the purity rule).
function seededRnd(seed: number) {
let s = seed
return () => {
s = (s * 16807) % 2147483647
return s / 2147483647
}
}
That is MINSTD, the Park-Miller minimal standard: multiply by 16807, take the remainder mod 2^31 - 1, divide back into [0, 1). Nine lines, no state outside the closure, no dependencies. The important property is not that it is a good generator. It is a mediocre generator by modern standards. The important property is that seededRnd(9137) returns the identical sequence forever, so the factory is now pure with respect to its arguments, and running it a hundred times produces the same hundred identical fields.
The embers:
const geo = useMemo(() => {
const g = new THREE.BufferGeometry()
const pos = new Float32Array(EMBER_COUNT * 3)
const seed = new Float32Array(EMBER_COUNT)
const rnd = seededRnd(9137)
for (let i = 0; i < EMBER_COUNT; i++) {
pos[i * 3] = -80 + rnd() * 150
pos[i * 3 + 1] = 2.5 + rnd() * 32
pos[i * 3 + 2] = -80 + rnd() * 150
seed[i] = rnd()
}
g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
g.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1))
g.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 16, 0), 130)
return g
}, [])
Note aSeed: each ember carries its own random number into the vertex shader, which is what makes them flicker and rise out of phase with each other. The per-ember life is still random-looking. It is just random once, at a fixed place in a fixed sequence, instead of random again on the next render.
The city lights got seed 4451 and the same treatment.
The real payoff is not the lint rule, it's being able to compare two screenshots
Fixing this to satisfy a linter would be a waste of a good lesson. The reason it actually matters is that I iterate on art by capturing the same frame twice and looking at what changed.
Before the fix, any two captures of the ridge differed in 320 ember positions and 110 city lights, on top of whatever I had actually changed. Every comparison had noise in it that had nothing to do with my edit, and I could not tell "I made the glow warmer" apart from "the sky reshuffled." With a fixed seed, two captures of the same camera at the same sim time are byte-comparable except for the thing I touched. Determinism in the scatter is what turns a screenshot into a measurement.
Same reason the ridge silhouettes were seeded in the first place. I just did not connect the two until the linter connected them for me.
The second trap: a memoized value may not be mutated, ever
The other two errors in that probe file are the same rule twice, and they are the more interesting half:
14:3 error Error: This value cannot be modified
Modifying a value previously passed as an argument to a hook is not allowed.
Consider moving the modification before calling the hook.
react-hooks/immutability
15:3 error Error: This value cannot be modified
Modifying a variable defined outside a component or hook is not allowed.
Consider using an effect.
The car in this scene has tail lights, headlamps, and a sprite for the headlight beam on the driveway. All three change every frame: the lamps go from dim to hot when the engine turns over, the beam fades in. That means writing to a THREE.Material sixty times a second.
The obvious spot for a material is a useMemo. That is wrong for two separate reasons, and the second is the one that bites.
Reason one is the rule above: a value that came out of a hook is React's, and mutating it is out of bounds. Reason two is that even if the linter let me, a memo is not a promise the object survives. If React drops the cache and rebuilds the material, my per-frame writes were going into an object nothing renders anymore, and the lamps would silently stop responding. That failure is intermittent, invisible in dev, and impossible to reproduce on demand. No thanks.
So they live at module scope:
// Lamps are driven per-frame from the sim, so they live at module scope: a
// useMemo value may not be mutated. The beam needs a canvas texture, so it is
// built on first render (client-only) and cached like the textures above.
const tailMat = new THREE.MeshStandardMaterial({ color: '#3a0505', emissive: '#ff1806', emissiveIntensity: 0.9 })
const headMat = new THREE.MeshStandardMaterial({ color: '#20242a', emissive: '#cfe0ff', emissiveIntensity: 0.15 })
Here is the part that is easy to get wrong. Module scope alone does not pass. Look at that third lint error again: modifying a module-level variable is also flagged. The escape is not where the object lives, it is when the write happens. Writing during render is out regardless of where the object came from. Writing from a frame callback is a different phase entirely, and the linter does not follow me there:
useFrame(() => {
const sim = getSim()
if (!group.current) return
const driving =
sim.state.phase === 'driving' || sim.state.phase === 'cot' || sim.state.phase === 'over'
// ...
tailMat.emissiveIntensity = driving ? 3.2 : 0.9
headMat.emissiveIntensity = driving ? 3.0 : 0.15
beamMaterial().opacity = driving ? 0.75 : 0
})
useFrame runs on the render loop, after commit, outside React's render phase. From there, writing to a material is just writing to an object.
The beam sprite needed one more wrinkle, because its material wants a canvas texture and canvas does not exist during server rendering. Module scope would run document.createElement at import time. So it is lazy, and cached in a module-level variable rather than a hook:
let beamMatCache: THREE.SpriteMaterial | null = null
function beamMaterial() {
if (!beamMatCache) {
beamMatCache = new THREE.SpriteMaterial({
map: glowTexture(),
color: '#ffeecb',
transparent: true,
opacity: 0,
depthWrite: false,
blending: THREE.AdditiveBlending,
})
}
return beamMatCache
}
A module-level cache is not a React cache. React cannot decide to evict it, which is precisely why the per-frame writes are safe.
The line I'd draw before writing the next scene
Both bugs are the same misunderstanding wearing two costumes. I was treating useMemo as an allocation arena, a place to stash the objects a Three.js scene needs, when it is a cache with an explicit right to be wrong.
The split that actually holds up:
Render decides what exists. The tree of meshes, which geometry is attached, what props they take. This is React's, it must be pure, and any randomness in it needs a seed so that "pure" is true and not just plausible.
The frame loop decides what things look like right now. Positions, emissive intensities, opacities, shader uniforms. None of these belong to React at all. They belong to objects React never gets to reason about, mutated from useFrame, and the components only hand out refs.
The test I'll run on the next scene: for every object I create, can React throw it away and rebuild it between two frames without anything breaking? If yes, useMemo is fine. If no, it was never a memo. It was state with a lifetime longer than a render, and it needs to live somewhere React does not own.