Skip to content

Conversation

@philwolstenholme
Copy link

@philwolstenholme philwolstenholme commented Dec 30, 2025

There's no point letting JavaScript/React update more often than the browser repaints. requestAnimationFrame automatically syncs our updates to the display's refresh rate. This is less opinionated than using throttling or debouncing.

The API stays identical, consumers of the hook don't need to change anything but we should see some performance improvements on low-end devices.

How this relates to other places where Mantine uses requestAnimationFrame

I looked at hooks like useScrollIntoView and useResizeObserver that also use requestAnimationFrame and named my ref the same as theirs (frameID).

There were a few areas where I was deliberately inconsistent:

  • useResizeObserver and useMove reset the value of frameID.current each time by cancelling the existing frame ID then rescheduling a new one. My approach is to skip if a frame is already scheduled. This prevents unnecessary cancelAnimationFrame() calls on every event and means that events are handled in the very next frame rather than being delayed by the cancel existing frame -> schedule a new one -> handle event chain.

  • useResizeObserver uses 0 as the default value, I use null. This is based on this MDN comment:

    The request ID is typically implemented as a per-window incrementing counter. Therefore, even when it starts counting at 1, it may overflow and end up reaching 0. While unlikely to cause issues for short-lived applications, you should avoid 0 as a sentinel value for invalid request identifier IDs and instead prefer unattainable values such as null.

    as well as the semantics of null more clearly meaning "no frame is scheduled"

How is this different to #8287?

Given that #8287 caused #8320 we want to make sure the same thing doesn't happen again.

Use of the Mantine useWindowEvent hook

#8287 used the native browser API to set its event listeners, not useWindowEvent.

Memory leak/stale closure bug

#8287 seems to have a stale closure value bug so the RAFs are never cleaned up:

useEffect(() => {
  let rafID: number | null = null;  // The rafID is captured when the cleanup closure is created
  
  function updatePosition() {
    rafID = window.requestAnimationFrame(() => { ... });  // Assigns AFTER closure is created
  }
  
  return () => {
    if (rafID) {  // This always sees the initial value (null)
      cancelAnimationFrame(rafID);  // This never executes
    }
  };
}, []);

My approach uses useRef, and rafRef.current will always be the latest value.

Unnecessary renders

IN #8287 we always call getScrollPosition and always update the value of the position state:

setPosition(getScrollPosition());  // Always updates, even if the scroll position has not changed

In my approach we don't update the state if the scroll position has not changed:

setPosition((prev) => {
  const next = getScrollPosition();
  if (prev.x === next.x && prev.y === next.y) {  // Bail out by returning `prev` (a stable reference, so no state change happens and no re-render occurs)
    return prev;
  }
  return next;
});

This prevent re-renders, for example it does not change any state during a resize event.

My approach adds passive: true to the scroll and resize listeners

Without the passive option browsers can't optimise scrolling and must run the scroll handler before the scroll can finish. This blocks the main thread and can lead to jankiness.

Single value for deduplication and cleanup

In my approach the RAF ID serves two purposes (tracking + deduplication). #8287 needs two variables (ticking + rafID), one for deuplication and one for cleanup.

Summary of the changes

The main change is to the function that is run on each scroll and resize event:

Aspect Before After Why
Handler definition Inline use Extracted handleScroll
Memoization None useCallback We memoise the scroll handler with useCallback so that it becomes a stable reference to pass to useWindowEvent (this is necessary because useWindowEvent has listener in its dependencies and functions are not stable references)
Throttling None requestAnimationFrame Limits updates to ~60fps, synced with paint
Deduplication None rafRef guard Prevents queueing multiple frames
State update setPosition(getScrollPosition()) Functional update with equality check Skips re-render when position unchanged
Event options None { passive: true } By default, the browser has to wait for a scroll handler to complete before it knows whether the handler will block the scroll. With { passive: true }, the browser can start scrolling immediately in parallel with running handlers

We use a ref to store the ID of any scheduled requestAnimationFrame. It serves two purposes:

  • Allows us to check if a frame is already scheduled (helps with deduplicating work)
  • Allows us to cancel pending frames on unmount (helps with cleanup)

The RAF throttling flow:

Event #1 → rafRef is null → schedule RAF, set rafRef = id
Event #2 → rafRef is not null → early return (skip)
Event #3 → rafRef is not null → early return (skip)
RAF fires → clear rafRef, update state
Event #4 → rafRef is null → schedule new RAF

Phil Wolstenholme added 2 commits December 30, 2025 13:34
@philwolstenholme philwolstenholme changed the title Draft: feat: use RAF in useWindowScroll to throttle updates and sync with browser rendering Draft: [@mantine/hooks] use-window-scroll: use RAF to throttle updates and sync with browser rendering Dec 30, 2025
@philwolstenholme philwolstenholme changed the title Draft: [@mantine/hooks] use-window-scroll: use RAF to throttle updates and sync with browser rendering [@mantine/hooks] use-window-scroll: use RAF to throttle updates and sync with browser rendering Jan 2, 2026
@philwolstenholme
Copy link
Author

philwolstenholme commented Jan 2, 2026

I wrote some browser tests and it turns out that modern browsers (Chrome and Safari both seem to) throttle input events like onscroll to match the animation frame rate of the device. MDN agrees:

Note that you may see code that throttles the scroll event handler using requestAnimationFrame(). This is useless because animation frame callbacks are fired at the same rate as scroll event handlers. Instead, you must measure the timeout yourself, such as by using setTimeout().

I think my change still has merit because of:

  • not all browsers/engines might synchronise input events with refresh rates
  • adding passive: true will be useful
  • using useCallback will make the handler a stable reference for useWindowEvent
  • returning prev instead of re-creating the { x: window.scrollX, y: window.scrollY } object and forcing a re-render even if the scroll position has not changed (e.g. when a user resizes the window)

@philwolstenholme philwolstenholme changed the title [@mantine/hooks] use-window-scroll: use RAF to throttle updates and sync with browser rendering [@mantine/hooks] use-window-scroll: optimisations to reduce re-rendering on resize and scroll Jan 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant