Avoiding the R3F Re-render Storm When Adding Objects Dynamically
Every R3F project hits this eventually. You add a fifth GLTF model and the entire scene flickers. Add the tenth and the framerate halves. The cause isn't React or Three.js — it's the subscription pattern.
Three patterns we use in Yugma that scale to hundreds of objects.
# Pattern 1 — per-object subscriptions
Don't subscribe to the whole objects map. Subscribe per-id at the leaf:
function SceneObjectMesh({ objectId }: { objectId: string }) {
// Only re-renders when this id's value changes
const obj = useSceneStore((s) => s.objects[objectId])
if (!obj) return null
return <mesh position={obj.transform.position} />
}
function Scene() {
// Subscribe only to ids list (changes on add/remove, not on update)
const ids = useSceneStore((s) => s.objectOrder)
return (
<>
{ids.map((id) => <SceneObjectMesh key={id} objectId={id} />)}
</>
)
}
Adding object N+1 does not re-render objects 1..N.
# Pattern 2 — read in handlers, not props
Inside callbacks (mouse, keyboard, AI dispatch), use useSceneStore.getState() instead of subscribing. The handler doesn't re-render when the scene mutates — only when its containing component re-renders for other reasons.
function Toolbar() {
// ❌ Don't: re-renders on every scene mutation
// const objects = useSceneStore((s) => s.objects)
function handleClick() {
// ✅ Do: read at fire time
const { objects, selectedObjectId } = useSceneStore.getState()
// …
}
return <button onClick={handleClick}>Action</button>
}
# Pattern 3 — refs over state for transforms
When TransformControls drags an object, don't setState every frame. Write the transform straight to the Three.js object via ref, then commit to state on pointerup.
const groupRef = useRef<THREE.Group>(null)
const onTransformChange = () => {
// No setState here — reading the gizmo's frame-by-frame state
// would force React to re-render every frame.
}
const onTransformEnd = () => {
// Commit once when the user lets go
const g = groupRef.current
if (!g) return
updateObject(objectId, {
transform: {
position: [g.position.x, g.position.y, g.position.z],
rotation: [g.rotation.x, g.rotation.y, g.rotation.z],
scale: [g.scale.x, g.scale.y, g.scale.z],
},
})
}
This is the trick that lets Yugma keep 60fps while a designer drags handles around a 200-object scene.
# Bonus: instancing for repeated geometry
If your scene has 200 chairs, use Drei's instead of 200 mesh nodes. The CPU cost is one mesh; the GPU draws all 200 in one draw call.
import { Instances, Instance } from '@react-three/drei'
<Instances limit={200}>
<boxGeometry args={[0.5, 0.9, 0.5]} />
<meshStandardMaterial color="#a37b53" />
{chairs.map((c, i) => <Instance key={i} position={c.position} />)}
</Instances>
# What we measure
In Yugma we run a Perf HUD inside the studio. Frame budget: 16ms. Per-pattern:
- Per-object subscriptions: scales to 500+ objects on M1 MBP at 60fps.
- Without instancing: ~200 meshes before frame budget pressure.
- With instancing: 5,000+ instances at 60fps.
The R3F re-render storm is solvable. You just have to stop subscribing too widely.