How to Fix: Scroll restoration triggers before navigation after revalidation

6 min read

Scroll restoration is firing at the wrong time because the app restores the previous scroll position during revalidation before the next navigation has fully committed.

In the reported reproduction, the browser ends up applying an old scroll state too early. The result is a jarring jump: users navigate, loaders revalidate, and the page restores scroll for the current history entry before the destination route is actually ready. This creates the impression that scroll restoration is “winning” over navigation.

If you are debugging a router-driven app such as the one shown in the reproduction sandbox, the key is to treat scroll restoration and navigation completion as two separate phases. The bug appears when restoration is triggered off revalidation state changes instead of the finalized location update.

Understanding the Root Cause

This issue happens because revalidation can occur independently of a full route transition. In data routers, a navigation may start, loaders may re-run, and internal state may temporarily reflect “work in progress” before the final route tree is committed. If scroll restoration logic listens too early, it can restore scroll for the wrong entry.

Technically, the sequence usually looks like this:

  1. The user is on a page with an existing saved scroll position.
  2. A navigation starts.
  3. A loader action or revalidation event updates router state.
  4. Scroll restoration logic sees state changes and restores scroll immediately.
  5. The destination navigation finishes afterward, making the earlier restore incorrect.

The underlying problem is a timing mismatch between:

  • history state and saved positions
  • router revalidation lifecycle
  • DOM paint timing for the next route

In short, restoration should happen only after the router knows which location is final and the UI for that location is ready to be restored. If it runs during an intermediate revalidation state, the scroll manager applies a stale value.

This is especially visible when:

  • the previous page was scrolled deeply
  • the next page has different content height
  • loaders resolve quickly enough to trigger state changes before navigation completes
  • pop-up or separate window testing makes browser scroll behavior easier to notice

Step-by-Step Solution

The fix is to delay scroll restoration until the navigation has actually settled on the next location. You want restoration to key off the committed location and idle navigation state, not a transient revalidation update.

1. Restore scroll only after navigation is complete

If you own the scroll restoration logic, guard it so it runs only when navigation is idle and the current location key matches the entry you intend to restore.

import { useEffect, useRef } from "react";
import { useLocation, useNavigation } from "react-router-dom";

export function useStableScrollRestoration(getSavedPosition) {
  const location = useLocation();
  const navigation = useNavigation();
  const restoredKeyRef = useRef(null);

  useEffect(() => {
    if (navigation.state !== "idle") return;
    if (restoredKeyRef.current === location.key) return;

    const savedPosition = getSavedPosition(location.key);

    requestAnimationFrame(() => {
      window.scrollTo(
        savedPosition?.x ?? 0,
        savedPosition?.y ?? 0
      );
      restoredKeyRef.current = location.key;
    });
  }, [location.key, navigation.state, getSavedPosition]);
}

Why this works:

  • It waits for navigation.state === “idle”.
  • It restores based on the final location.key.
  • It avoids duplicate restoration for the same history entry.
  • requestAnimationFrame pushes the scroll update until after the next paint cycle, reducing race conditions with layout.

2. Ignore pure revalidation updates

If your logic is triggered by generic router state changes, add a condition so a revalidation alone does not restore scroll.

useEffect(() => {
  const isNavigationComplete = navigation.state === "idle";
  const hasCommittedLocation = currentLocationKey === location.key;

  if (!isNavigationComplete || !hasCommittedLocation) {
    return;
  }

  restoreScrollForLocation(location.key);
}, [navigation.state, currentLocationKey, location.key]);

The important idea is that revalidation is not the same as navigation completion. Treat them differently.

3. Save scroll positions by history entry, not just pathname

Another common source of incorrect behavior is storing positions by pathname only. If multiple history entries share the same path, restoration can pull the wrong value.

const scrollPositions = new Map();

function saveScrollPosition(locationKey) {
  scrollPositions.set(locationKey, {
    x: window.scrollX,
    y: window.scrollY,
  });
}

function getSavedPosition(locationKey) {
  return scrollPositions.get(locationKey);
}

Using the location key is more reliable than using only pathname.

4. Save scroll before leaving the current route

Make sure the outgoing route’s scroll position is captured before navigation replaces the current screen.

import { useEffect } from "react";
import { useLocation } from "react-router-dom";

export function useSaveScrollOnLeave(saveScrollPosition) {
  const location = useLocation();

  useEffect(() => {
    return () => {
      saveScrollPosition(location.key);
    };
  }, [location.key, saveScrollPosition]);
}

This ensures the previous page has an accurate saved position when the user returns via back/forward navigation.

5. If needed, defer until data and layout are stable

Sometimes the route becomes idle before async content fully affects layout. In that case, perform restoration after the page has enough content to support the saved scroll range.

useEffect(() => {
  if (navigation.state !== "idle") return;

  const id = requestAnimationFrame(() => {
    const saved = getSavedPosition(location.key);
    if (!saved) return;

    const maxY = document.documentElement.scrollHeight - window.innerHeight;
    const targetY = Math.min(saved.y, Math.max(maxY, 0));

    window.scrollTo(saved.x, targetY);
  });

  return () => cancelAnimationFrame(id);
}, [navigation.state, location.key, getSavedPosition]);

This clamping step prevents restoring to a scroll value that is larger than the rendered document height.

6. Validate behavior against the reproduction

When testing against the issue sandbox, verify these outcomes:

  • Navigation to the next page does not jump to a previous scroll position mid-transition.
  • Back/forward navigation restores the expected position for that exact history entry.
  • Revalidation on the same page does not trigger an unintended scroll reset.

Common Edge Cases

Same pathname, different state

If the app navigates to the same route with different search params or route state, storing scroll by pathname can restore the wrong position. Prefer location.key or a compound key.

Content height changes after images load

If images, deferred loaders, or lazy components expand the page after restoration, users may land at the wrong visual section. In those cases, delay restoration until the content required for layout is present.

Nested scroll containers

Not every app scrolls the window. Some layouts use a dedicated container with overflow: auto. If so, restoring only window.scrollTo() will appear broken. Restore the container’s scrollTop instead.

const container = document.getElementById("page-scroll-container");
if (container) {
  container.scrollTop = savedPosition?.y ?? 0;
}

Hash navigation

If the destination URL includes a fragment like #details, hash scrolling may compete with manual restoration. Usually, anchor navigation should take precedence over generic saved scroll positions.

Strict Mode double effects

In development, React Strict Mode can invoke effects more than once. Without a guard such as a restored key ref, scroll restoration may run twice and hide the real timing bug.

Browser-native restoration conflicts

If the browser is also trying to manage history scroll restoration, your custom logic may fight with it. Set the browser behavior explicitly when necessary.

if ("scrollRestoration" in window.history) {
  window.history.scrollRestoration = "manual";
}

FAQ

Why does this bug appear mostly during revalidation?

Because revalidation changes router state before the final route transition is fully committed. If your scroll logic subscribes to those intermediate updates, it can restore too early.

Why is waiting for navigation idle not always enough?

Idle is a strong signal, but some pages still change height after idle due to images, deferred data, or lazy rendering. In those cases, add a paint delay with requestAnimationFrame or restore after required content is mounted.

Should scroll be keyed by pathname or location key?

Use location.key whenever possible. Pathnames are too coarse for history-based restoration because different entries can share the same URL but represent different user positions.

Final Fix Summary

To solve “scroll restoration triggers before navigation after revalidation,” move restoration logic so it runs only after the destination location has fully committed, ignore intermediate revalidation-only state changes, store positions by history entry key, and defer restoration until the DOM is ready. That combination removes the race condition and makes back/forward scroll behavior predictable again.

Leave a Reply

Your email address will not be published. Required fields are marked *