All notes
08Note6 min

Keeping scroll progress out of React

  • React
  • Performance
  • WebGL

A value that changes every frame does not belong in state. How this site drives a WebGL scene from scroll without re-rendering the tree sixty times a second.

The homepage is one object seen many ways: a layered sphere behind the content that rotates, peels, explodes into labelled shells and collapses to a core as you scroll. Every one of those beats is a function of a single number — how far down the document you are.

The obvious implementation is a scroll listener that calls setState. It is also the wrong one, and the reason is worth being precise about: scroll progress changes on every frame, so putting it in state means re-rendering the entire component tree sixty times a second to update a value that only three consumers actually read.

The store is a plain object

There is no context, no reducer and no subscription for the per-frame value. GSAP's ScrollTrigger writes progress into a module-level object, and the render loop reads it directly.

ts
// lib/scroll-store.ts — the per-frame channel
export const scrollStore = {
  progress: 0,   // written by ScrollTrigger
  smoothed: 0,   // damped once per frame, read by useFrame
  hoveredLayer: -1,
}

// components/three/layer-stack.tsx — read per frame, never via state
useFrame((_, delta) => {
  const bounds = getBounds()            // NOT a captured import
  const t = scrollStore.smoothed
  // ...drive rotation, separation and dim from t
})

React never learns that scroll happened. The canvas is mounted once and never unmounted; the only things that re-render on scroll are the handful of components that genuinely need to, and they subscribe explicitly.

The DOM readouts are the interesting case

The instrument panel prints live numeric values — separation coefficient, depth, current beat. These are DOM, not WebGL, so they cannot ride the useFrame loop. They get their own requestAnimationFrame callback that writes textContent directly, bypassing React entirely.

The number on screen is the number driving the geometry — the readout computes the same expression from the same bounds the shells use, rather than being told what to display.

That is a small discipline with a real payoff: the readouts cannot drift from the object, because there is no second source for them to drift towards.

The trap: captured bounds go stale

Section boundaries are derived from CSS heights, then re-measured from real offsetHeight after mount and on every ScrollTrigger refresh. Re-measuring replaces the bounds object rather than mutating it, which sets a trap for anything holding a reference.

ts
// ✗ captured at module import — points at the pre-measurement object forever
import { SECTION_BOUNDS } from '@/lib/scroll-store'
const t = SECTION_BOUNDS.services.start

// ✓ read per call, inside the frame callback
const t = getBounds().services.start

React components take the third path and subscribe via subscribeBounds, so they re-render on measurement rather than polling for it. Three consumers, three access patterns, one source of truth.

When this is worth doing

  • The value changes at frame rate — scroll, pointer position, audio amplitude, elapsed time.
  • Its consumers are few and identifiable, so bypassing the tree costs you nothing in maintainability.
  • You already have a render loop (useFrame, rAF) that runs regardless.

If any of those is false, use state. This pattern buys frames at the cost of the framework's guarantees, and that is only a good trade when the frames are genuinely at risk.

We build this way for clients too.

Start a project