@vectorvesper/motion/reactReact · Shared Frame Loop

useTick

The obvious way to animate from a component is a requestAnimationFrame loop inside an effect. Five components doing that is five loops the browser must run, five interleavings of reads and writes, and no way for any of them to yield when the frame is already full.

useTick subscribes a callback to one lane of the shared frame loop for the lifetime of the component, and unsubscribes on unmount. It is the React binding over the conductor, and the answer to almost every "I need something to run every frame" in a component.

Anything rendered inside an interaction region joins it automatically, so work in the part of the page a visitor is actually using keeps its frames while ambient work elsewhere yields first. That happens without per-child configuration, and it is the one thing subscribing to the conductor by hand cannot do for you.

It causes no React re-renders. The callback writes to refs or straight to the DOM; putting a per-frame value into state re-renders the component sixty times a second, which is the cost this runtime exists to remove.

Quick start

Pick a lane, write to the DOM through a ref, and give it a label so it is identifiable in devtools.

dt and time are both in seconds, not milliseconds. dt is already clamped, so a backgrounded tab waking up cannot feed a huge step into damping maths.

Lanes, and why position matters as much as priority

Each frame runs three lanes in a fixed order. Reading in input, computing in update and writing in render means every read on the page finishes before any write begins, which keeps layout to one calculation per frame however many subscribers there are.

Mixing them reintroduces exactly the thrashing the split prevents. A layout read in the render lane runs after other subscribers have already written, so it forces the browser to recalculate synchronously, every frame. A DOM write in the input lane dirties layout underneath every subscriber still reading, including the runtime's own sensors and gates.

Shedding is the part that surprises people. The threshold is measured against how much of the frame budget this runtime has already spent when a subscriber's turn arrives, and the check happens before it runs. A subscriber that runs first in its lane therefore never sheds, however overloaded the page is. Priority sets the threshold; position decides whether the threshold is ever reached.

That is why hz matters for decorative motion whose position the eye follows. Shedding is regular but lands near 12fps, which reads as broken for tracked motion, while capping the cadence at 30 looks fine and does less total work.

API Reference

Syntax

Subscribes for the lifetime of the component. There is nothing to unsubscribe by hand and nothing returned.

Parameters

Parameters.fieldTypeDefaultDescription
lane"input" | "update" | "render"(required)Which phase to run in. Read the DOM and sensors in input, do maths in update, write to the DOM or canvas in render.
fn(dt: number, time: number) => void(required)Called once per frame. dt is seconds since this subscriber last ran, already clamped. time is the shared clock, also in seconds.
options.priority"essential" | "enhanced" | "decorative""enhanced"Shed order when a frame runs long. Reserve essential for sensors, governors and direct manipulation, because a scrub that stutters is a broken scrub. Decorative yields first.
options.hznumber0Cap the cadence, in runs per second. The accumulated dt is passed through, so damping stays correct at any cadence.
options.labelstring"anonymous"Name shown in devtools and in slow-subscriber warnings. Worth setting every time.
options.enabledbooleantrueSet false to unsubscribe without unmounting the component.
options.backgroundbooleanfalseOpt out of the surrounding InteractionScope, for ambient work that happens to be rendered inside a region a visitor interacts with.

Return Value

void

Nothing. The subscription is owned by the hook and released when the component unmounts.

Lifecycle & Invariant Guarantees

  • One loop, not one per component: Every subscriber shares a single requestAnimationFrame chain, so the scheduler can order reads before writes, measure what each one costs, and shed decorative work when a frame runs long.
  • The callback never goes stale: The latest closure is held in a ref behind a stable subscription. The callback always sees fresh props, and the subscription itself never churns, so the subscriber's measured cost average survives a re-render.
  • Scope membership is automatic: Membership is read from context, not passed as an argument. Rendered inside an InteractionScope means joined to it.
  • Zero re-renders per frame: The hook returns nothing and sets no state. Whatever the callback changes, React is not involved.

Production Examples

Battle-tested production patterns ready to copy directly into your codebase.

Read sensors in input, write transforms in render, and smooth with damp so it feels the same at 60Hz and 120Hz.

When NOT to use this

You are animating a single element from A to B on an event.
→ instead A CSS transition or the Web Animations API. Both run off the main thread and cost you nothing per frame.
The work is not per-frame.
→ instead An ordinary effect, a timer, or an event handler. A frame subscription for something that changes on an event runs sixty times a second to discover nothing changed.
You are outside React.
→ instead getConductor().subscribe(lane, fn, options), which is what this wraps. It takes an explicit scope where this reads one from context, and you own the unsubscribe it returns.

Rules & Gotchas

  • Set a label: It is the difference between a readable devtools row and a row called anonymous.
  • Write through a ref, never through state: Calling setState from a frame callback re-renders sixty times a second, which is the exact cost this runtime exists to remove.
  • Read in input, compute in update, write in render: Mixing them reintroduces the layout thrashing the lanes prevent, and check_motion reports it as lane-discipline.
  • Never start a second requestAnimationFrame loop beside it: A private loop is invisible to the budget, cannot be shed, and the frame-pressure classifier bills its time to main-thread, so the page cannot even tell the cost is its own.
  • Do not allocate inside the callback: Sixty allocations a second is a garbage collection pause with a delay on it.

Related Motion Components