setAnimationLoop

The Render Loop with setAnimationLoop

setAnimationLoop(callback) calls callback(time) before each display refresh, uses the headset's frame loop in WebXR (WebXR), and stops on null. Move things by elapsed time, as Import Maps did, or a 120 Hz screen spins twice as fast. For deltas, THREE.Timer replaced the deprecated Clock in r183; timer.connect(document) avoids a huge delta when a hidden tab returns. With a timer and the shelf:

Turning the shelf at 0.5 radians per second for 120 framesJavaScript
let frames = 0;
renderer.setAnimationLoop(() => {
  timer.update();
  shelf.rotation.y += 0.5 * timer.getDelta();
  renderer.render(scene, camera);
  if (++frames < 120) return;
  renderer.setAnimationLoop(null);
  const angle = shelf.rotation.y.toFixed(2);
  console.log(frames, 'frames,', timer.getElapsed().toFixed(2), 's, angle', angle);
});
Output
120 frames, 2.04 s, angle 1.02

A scene that changes only on input can skip the loop and call render() from its event handlers.

A setAnimationLoop render loop driven by THREE.Timer, with start and stopHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
  canvas { display: block; max-width: 100%; }
  .ui { padding: 6px 8px; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="ui"><button id="toggle">Stop</button> <span id="out"></span></div>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 280);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 280, 0.1, 50);
camera.position.set(0, 1.2, 4.2);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);

const shelf = new THREE.Group();
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.4), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
shelf.add(plank);
['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].forEach((color, i) => {
  const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12), new THREE.MeshStandardMaterial({ color }));
  book.position.x = (i - 2.5) * 0.5;
  shelf.add(book);
});
scene.add(shelf);

// THREE.Timer (r183+) measures deltas; connect(document) ignores time spent in a hidden tab
const timer = new THREE.Timer();
timer.connect(document);
let frames = 0, running = true;
const out = document.getElementById('out');

function loop() {
  timer.update();
  shelf.rotation.y += 0.5 * timer.getDelta();      // 0.5 rad per second at any refresh rate
  renderer.render(scene, camera);
  if (++frames % 20 === 0) {
    out.textContent = `${frames} frames, ${timer.getElapsed().toFixed(2)} s, angle ${shelf.rotation.y.toFixed(2)} rad`;
  }
}
renderer.setAnimationLoop(loop);

document.getElementById('toggle').addEventListener('click', (e) => {
  running = !running;
  renderer.setAnimationLoop(running ? loop : null);  // null stops the loop
  if (running) timer.reset();                        // no jump after a pause
  e.target.textContent = running ? 'Stop' : 'Start';
});
</script>