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.field | Type | Default | Description |
|---|---|---|---|
| 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.hz | number | 0 | Cap the cadence, in runs per second. The accumulated dt is passed through, so damping stays correct at any cadence. |
| options.label | string | "anonymous" | Name shown in devtools and in slow-subscriber warnings. Worth setting every time. |
| options.enabled | boolean | true | Set false to unsubscribe without unmounting the component. |
| options.background | boolean | false | Opt out of the surrounding InteractionScope, for ambient work that happens to be rendered inside a region a visitor interacts with. |
Return Value
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
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.