useThreeScene
A hand-written three.js hero needs about ten things right besides the scene itself: when to build, when to draw, the pixel ratio, resizing, a lost context, disposal and reduced motion. Generated code gets about half of them, and each one it misses fails silently, on somebody else's device.
useThreeScene does for a hand-written three.js scene what useSceneGate and useRenderQuality do for React Three Fiber, in one hook. You make the renderer and build the scene. It does everything around them.
It builds nothing until the element is near the viewport and the page can afford it, draws on the shared frame loop and only while on screen, keeps the pixel ratio at or below the screen's own, follows the element's size, rebuilds after a lost context, frees every GPU resource when the scene goes, and never builds under reduced motion or on a device below the floor.
New in 4.2.0, in its own entry. It imports nothing from three and reads only the renderer's shape, so a WebGPURenderer works the same way. In our test lab, a plain three.js hero rewritten with it passed every check the hand-wired version passes, on Chrome, Safari's engine, desktop and mobile, in one file instead of two and about ten separate steps.
Quick start
Give the element a size, build the scene in setup, and show a still while mounted is false.
Make every three.js object inside setup. A rebuild after a lost context calls it again on a fresh renderer, and objects made anywhere else belong to the dead context.
How it runs a scene
One renderer per build, one callback on the shared render lane, and a teardown that runs in reverse.
It composes the scene gate rather than reimplementing it, so a scene is built only once the gate says the element is near, the page has frame headroom and scrolling has settled. Once built, a scene is kept while it is off screen: the gate reports idle, drawing stops, and the renderer, its context and every buffer stay as they are, so coming back costs nothing.
Each build is one renderer on one graphics context. The hook calls your renderer factory, puts its canvas inside the element, sizes it, and attaches the context-loss listener in the same task, so no loss can land before something is listening. Then it calls setup and subscribes one callback to the conductor's render lane. That callback skips the frame while the scene is idle or its context is dead. Otherwise it applies the pixel ratio for the current quality, calls update, draws, and after the first successful draw reports the context healthy, which is what ends a recovery.
The pixel ratio is the smaller of the screen's own ratio and the cap for the current quality, two at full and one at reduced unless you set others, and it changes on the renderer that already exists: a quality change never rebuilds anything. Size changes are applied at the next drawn frame rather than at once, because resizing clears the canvas and a scene that is off screen would otherwise sit cleared until it drew again.
A lost context moves the page's generation, and the hook tears the build down and makes a new one: a new renderer, a new canvas and a fresh call to setup, because nothing made on a dead context works on its replacement. The loss is reported with the generation the renderer was built under, so a replacement that dies moments after it was built still counts as a new failure. The clock handed to update carries on across the rebuild. A WebGPU renderer is waited for until its init resolves, and its device is watched through the same generation counter.
Teardown runs in reverse: the frame subscription stops, your dispose runs, every geometry, material and texture reachable from the scene is freed, the loss listener comes off, the renderer is disposed, its context is handed back with forceContextLoss, and the canvas leaves the page. The listener comes off before the context is released, so the hook never hears its own teardown as a failure.
API Reference
Syntax
Runs a three.js scene you build, with everything around it handled. R is your renderer's type: a WebGLRenderer or a WebGPURenderer.
Parameters
| Parameters.field | Type | Default | Description |
|---|---|---|---|
| options.renderer | () => R | Promise<R> | required | Makes the renderer: () => new THREE.WebGLRenderer({ antialias: true }). Called each time the scene is built, when it first comes near and again after a lost context, so it must return a new renderer every call. May return a promise, so three can be imported only when the scene is needed. |
| options.setup | (context) => handle | Promise<handle> | required | Builds the scene: camera, meshes, materials, lights, textures. Called right after renderer, every time the scene is built. Make every three.js object in here, not outside it. |
| options.label | string | undefined | Shown in devtools and in warnings. No effect on behaviour. |
| options.cost | "light" | "normal" | "heavy" | "heavy" | How expensive the scene is to start, passed to the scene gate. A full-screen hero is heavy; a small product turntable is normal. |
| options.preload | number | 200 | How far before the viewport to start building, in pixels. |
| options.maxDpr | { full?: number; reduced?: number } | { full: 2, reduced: 1 } | The highest pixel ratio each quality may draw at. Never above the screen's own, so a 1x monitor is never drawn at 2x. |
Return Value
Contains ref, mounted, state, quality, cause and reason.
Properties & State
| Properties.field | Type | Description |
|---|---|---|
| scene.ref | RefObject<HTMLDivElement | null> | Put it on the element the scene fills, and give that element a size. The canvas is added inside it. |
| scene.mounted | boolean | Whether the scene exists. While false (before it first comes near, under reduced motion, on a device below the floor) show a still image or a background in the element instead. |
| scene.state | SceneState | Where the scene is in its life, straight from the scene gate: dormant, warming, active, constrained, idle, recovering or poster. |
| scene.quality | "full" | "reduced" | null | How much scene is drawing, or null when there is none. frame.quality in update is the live value. |
| scene.cause | string | Why the scene is in its current state, in one word, such as off-screen, reduced-motion or context-lost. Branch on this. |
| scene.reason | string | The same, in a sentence, for a devtools row or a support thread. The wording may change. |
context
What setup is given.
| context.field | Type | Description |
|---|---|---|
| renderer | R | The renderer renderer() made, already in the page and sized. |
| width | number | The element's width, in CSS pixels. |
| height | number | Its height, in CSS pixels. |
| quality | "full" | "reduced" | The quality the scene starts at. frame.quality in update is the live value. |
handle
What setup returns. scene and camera are required; the rest are optional.
| handle.field | Type | Description |
|---|---|---|
| scene | THREE.Scene | Drawn with camera every frame. Everything in it is freed when the scene goes. |
| camera | THREE.Camera | What the scene is drawn from. A PerspectiveCamera's aspect follows the element's size. |
| update | (frame) => void | Called before each draw. Animate here. |
| render | (frame) => void | Draws the frame instead of renderer.render(scene, camera), for an EffectComposer or anything drawn in more than one pass. |
| resize | (width, height) => void | Called with the element's size before the first frame, and whenever the drawing buffer changes. Resize anything sized in device pixels here. |
| dispose | () => void | Called when the scene goes. Free what is not reachable from scene: render targets, composers, controls. |
frame
What update and render are given. The same object every frame, so nothing is allocated per frame; copy anything you want to keep.
| frame.field | Type | Description |
|---|---|---|
| dt | number | Seconds since the previous frame. |
| time | number | Seconds this scene has spent drawing. It stands still off screen and carries on across a rebuild. |
| quality | "full" | "reduced" | Live. Key anything cheap to change off it, such as how many particles draw. |
| pixelRatio | number | The pixel ratio the canvas draws at, live. Pass it to anything measured in device pixels, such as point sprite sizes. |
| width | number | The element's width, in CSS pixels. |
| height | number | The element's height, in CSS pixels. |
Lifecycle & Invariant Guarantees
- →Nothing is built until it is worth it: The scene is built only once the element is near the viewport, the page has frame headroom and scrolling has settled. Never under reduced motion or on a device below the floor.
- →An off-screen scene costs nothing: Scrolled away, drawing stops and the renderer, its context and every buffer stay as they are. Scrolling back draws the next frame without a rebuild.
- →A quality change never rebuilds: The pixel ratio is the smaller of the screen's own and the cap for the current quality, and it changes on the renderer that already exists.
- →Listening from the first moment: The loss listener goes on in the same task that creates the renderer, so no loss can land before something is listening. A replacement that dies moments after its rebuild still counts as a new failure.
- →Everything is handed back: Teardown frees every geometry, material and texture reachable from the scene, disposes the renderer and releases its context. The listener comes off first, so the hook never hears its own teardown as a failure.
◆Production Examples
Battle-tested production patterns ready to copy directly into your codebase.
Draw through an EffectComposer with render, keep it sized with resize, and release it in dispose. The bloom pass switches off at reduced quality, which is cheap; rebuilding it is not.
When NOT to use this
Rules & Gotchas
- →Make every three.js object inside setup: A rebuild after a lost context calls it again on a fresh renderer. Objects made at module scope, in a ref or in another effect belong to the dead context, and a renderer made at module scope also breaks server rendering.
- →Return a new renderer from renderer() every call: It is called again after every lost context. Handing back the old renderer hands back a dead context.
- →Do not draw or loop yourself: The hook draws every frame on the shared loop. A requestAnimationFrame or setAnimationLoop of your own draws the scene twice and keeps drawing off screen. Return render(frame) to draw differently.
- →Do not set the pixel ratio or the size: The hook owns both and overwrites yours whenever the element or the quality changes.
- →Change cheap things off frame.quality: A draw range or a pass's enabled flag is cheap to change. Rebuilding geometry every time quality moves is not.