Skip to content
usefy

useRafState

State & data

A drop-in <code>useState</code> that batches updates to <code>requestAnimationFrame</code> — one commit per frame.

Install

$ npm install @usefy/use-raf-state

Quick start

useRafState.tsx
import { useEffect } from "react";
import { useRafState } from "@usefy/use-raf-state";

function MouseFollower() {
  const [pos, setPos] = useRafState({ x: 0, y: 0 });

  useEffect(() => {
    const onMove = (e: PointerEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener("pointermove", onMove);
    return () => window.removeEventListener("pointermove", onMove);
  }, [setPos]);

  return (
    <div style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }}>
      following you
    </div>
  );
}

API reference

function useRafState<T>(initialState: T | (() => T)): [T, Dispatch<SetStateAction<T>>];
function useRafState<T = undefined>(): [T | undefined, Dispatch<SetStateAction<T | undefined>>];

The signature mirrors useState:

  • initialState — a direct initial value, or a lazy initializer () => T. The initializer is forwarded straight to the underlying useState, so it runs exactly once.
  • Returns a [state, setState] tuple. setState accepts a next value or a functional updater (prev) => next — the SetStateAction<T> is forwarded to the real setter, so updater semantics are preserved.

Coalescing semantics — last-write-wins

When you call setState, a frame is scheduled. If you call it again before that frame fires, the previously scheduled frame is cancelled and a new one is scheduled, so only the latest call in a frame commits:

setState(10);
setState(20);
setState(30); // → one re-render, commits 30

Functional updaters interact with coalescing the same way — only the last action survives, and it runs against the currently-committed state:

// committed state is 0
setState((n) => n + 1);
setState((n) => n + 1);
setState((n) => n + 1); // → commits 1 (not 3): only the last updater runs

This is the correct, expected behaviour for this hook's target use case, where each scroll/resize/pointer event sets an absolute value. If you need increments to accumulate within a single frame, don't rely on per-call updaters — compute the absolute next value yourself and set that (e.g. setState(base + delta)).

Behaviour notes

  • Stable setter — the returned setter has a constant identity (useCallback([])); pass it to children or list it in effect deps freely.
  • Cancel on unmount — the pending frame is cancelled on unmount; no state update fires afterwards.
  • SSR / unsupported environments — if requestAnimationFrame is unavailable at call time, the update is applied synchronously rather than dropped. The hook never references requestAnimationFrame at module top level, so importing/rendering on the server is safe.

Go deeper

This page is the quick reference. For every example, prop, and edge case, read the full README — or open Storybook to change props live.

More in state & data