DEV Community

Cover image for Delight your users with this instant scroll restoration custom hook

Delight your users with this instant scroll restoration custom hook

Mike Talbot ⭐ on October 04, 2023

TL;DR In a multipage application, pressing the back button should restore the scrolling position of the previous page so that the user ...
Collapse
 
bbutlerfrog profile image
Ben Butler

This is a really great idea (this solves a frustrating UI/UX issue with many React sites that I encounter as a user, and workarounds with back buttons on sites that bypass the browser are just bad), but this creates an issue with newer versions of Chrome that will not allow you to read the global ("Window") "localStorage" prop. Depending on user settings, it will give you an error and the script will die.

Collapse
 
miketalbot profile image
Mike Talbot ⭐ • Edited

Hmmm, yeah I just noticed that looking at incognito mode. I'll update it so it at least doesn't die! Thanks...

EDIT: Script now fixed. If there is no access to sessionStorage then scrolls will not be reloaded in a MPA. It will continue to work in a SPA.

Collapse
 
joelbonetr profile image
JoelBonetR 🥇 • Edited

@miketalbot @bbutlerfrog do you guys know if it'll also lock acces to indexedDB?

Because if the answer is NO then we might have an universal solution there, or even use a Cookie to store the position 😅 i know bad practices, you'll send that on every request to the server yada yada...

however...

Thread Thread
 
miketalbot profile image
Mike Talbot ⭐ • Edited

I've had a look about and it looks like it is restricted. I guess it would be possible to attempt to encode it on the URL...

Thread Thread
 
joelbonetr profile image
JoelBonetR 🥇

Yeah! I usually don't like to "pollute" the URL unless it's extremely necessary but... That might be the best idea then, the feature is probably great enough in several scenarios to justify it

Thread Thread
 
miketalbot profile image
Mike Talbot ⭐

I'll give it a go and post a follow up.

Thread Thread
 
miketalbot profile image
Mike Talbot ⭐

Ok that's done and this article and the Code Sandbox are updated with a version that will encode to the URL if it finds it can't access session storage.

Thread Thread
 
joelbonetr profile image
JoelBonetR 🥇

That's amazing thanks! 🤩

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Here's a TypeScript version if you prefer:

import { useCallback, useEffect, useRef } from "react";

const KEY = "useScrollRestoration-store";

interface ScrollInfo {
  top: number;
  left: number;
}

let scrolls: Record<string, ScrollInfo>;
let storageAvailable = true;

try {
  scrolls = JSON.parse(sessionStorage.getItem(KEY) ?? "{}");
} catch (e) {
  const params = new URLSearchParams(
    new URL(window.location.href).searchParams
  );
  storageAvailable = false;
  scrolls = JSON.parse(params.get("__scrollInfo") ?? "{}");
}

/**
 * Do nothing
 */
function noop(): void {}

function removeScrollParameter(href: string): string {
  href = href.replace(/__scrollInfo=[^&]+/gi, "");
  if (href.endsWith("/?")) return href.slice(0, -2);
  if (href.endsWith("/")) return href.slice(0, -1);
  return href;
}

/**
 * Custom hook for restoring scroll position based on a unique key. The hook returns a callback function
 * that should be set on the JSX element's ref attribute to manage scroll restoration.
 *
 * @param {string} [key=window.location.href] - A unique key to identify the scroll position, defaults to current URL.
 * @param {number} [timeout=500] - A timeout after which the scroll will not be restored, defaults to 1/2 a second.
 * @returns {Function} A callback function to set as the `ref` on a scrollable JSX element.
 *
 * @example
 * const scrollRef = useScrollRestoration();
 * return <div ref={scrollRef}>Your Content Here</div>;
 */

export function useScrollRestoration(
  key: string = window.location.href,
  timeout: number = 1500
): (ref: HTMLElement | null) => void {
  key = removeScrollParameter(key);
  const tracked = useRef<HTMLElement | null>(null);

  const updateTimer = useRef<NodeJS.Timeout>();
  const handler = useRef<Function>(() => {});
  const cleanUp = useRef<Function>(() => {});

  function disconnect(): void {
    handler.current();
    cleanUp.current();
  }

  function connect(ref: HTMLElement | null): void {
    function store(): void {
      scrolls[key] = {
        top: ref.scrollTop,
        left: ref.scrollLeft
      };
      clearTimeout(updateTimer.current);
      updateTimer.current = setTimeout(() => {
        if (storageAvailable) {
          sessionStorage.setItem(KEY, JSON.stringify(scrolls));
        } else {
          const url = new URL(window.location.href);
          const params = new URLSearchParams(url.searchParams);
          params.set("__scrollInfo", JSON.stringify(scrolls));
          url.search = params.toString();
          window.history.replaceState(null, null, url.toString());
        }
      }, 50);
    }
    disconnect();
    tracked.current = ref;
    if (ref) {
      ref.addEventListener("scroll", store);
      handler.current = () => {
        ref.removeEventListener("scroll", store);
      };

      const scrollInfo = scrolls[key];
      if (scrollInfo) {
        ref.scrollTop = scrollInfo.top;
        ref.scrollLeft = scrollInfo.left;
        const resizeObserver = new ResizeObserver(() => {
          if (
            ref.scrollHeight > scrollInfo.top ||
            ref.scrollWidth > scrollInfo.left
          ) {
            ref.scrollTop = scrollInfo.top;
            ref.scrollLeft = scrollInfo.left;
            cleanUp.current();
          }
        });
        setTimeout(() => cleanUp.current(), timeout);

        resizeObserver.observe(ref);
        cleanUp.current = () => {
          resizeObserver.unobserve(ref);
          cleanUp.current = noop;
        };
      }
    }
  }
  const connectRef = useCallback(connect, [key, timeout]);

  useEffect(() => {
    if (tracked.current) {
      connectRef(tracked.current);
    }
    return disconnect;
  }, [connectRef]);

  return connectRef;
}

Enter fullscreen mode Exit fullscreen mode
Collapse
 
starkraving profile image
Mike Ritchie

I wonder if you could use the history API? In addition to the URL you can give the location a data payload, which is available even when accessed by the back button. I don’t know if that is locked in privacy mode though

Collapse
 
miketalbot profile image
Mike Talbot ⭐ • Edited

The answer was yes, and it's much neater!!! Great call. Will probably write it up as a second article exploring some of the concepts. Here it is:

import { useCallback, useEffect, useRef } from "react";

/**
 * Custom hook for restoring scroll position based on a unique key. The hook returns a callback function
 * that should be set on the JSX element's ref attribute to manage scroll restoration.
 *
 * @param {string} [key=window.location.href] - A unique key to identify the scroll position, defaults to current URL.
 * @param {number} [timeout=500] - A timeout after which the scroll will not be restored, defaults to 1/2 a second.
 * @returns {Function} A callback function to set as the `ref` on a scrollable JSX element.
 *
 * @example
 * const scrollRef = useScrollRestoration();
 * return <div ref={scrollRef}>Your Content Here</div>;
 */

export function useScrollRestoration(
  key = window.location.href,
  timeout = 500
) {
  const updateTimer = useRef(0);
  const handler = useRef(noop);
  const cleanUp = useRef(noop);
  const connectRef = useCallback(connect, [key, timeout]);
  const tracked = useRef();

  useEffect(() => {
    if (tracked.current) {
      connectRef(tracked.current);
    }
    return disconnect;
  }, [connectRef]);

  return connectRef;

  function connect(ref) {
    disconnect();
    tracked.current = ref;
    if (ref) {
      ref.addEventListener("scroll", store);
      handler.current = () => {
        ref.removeEventListener("scroll", store);
      };

      const scrollInfo = window.history.state?.[key];
      if (scrollInfo) {
        ref.scrollTop = scrollInfo.top;
        ref.scrollLeft = scrollInfo.left;
        const resizeObserver = new ResizeObserver(() => {
          if (
            ref.scrollHeight > scrollInfo.top ||
            ref.scrollWidth > scrollInfo.left
          ) {
            ref.scrollTop = scrollInfo.top;
            ref.scrollLeft = scrollInfo.left;
            cleanUp.current();
          }
        });

        resizeObserver.observe(ref);
        cleanUp.current = () => {
          resizeObserver.unobserve(ref);
          cleanUp.current = noop;
        };
        setTimeout(() => cleanUp.current(), timeout);
      }
    }

    function store() {
      clearTimeout(updateTimer.current);
      updateTimer.current = setTimeout(() => {
        window.history.replaceState(
          {
            ...window.history.state,
            [key]: {
              top: ref.scrollTop,
              left: ref.scrollLeft
            }
          },
          ""
        );
      }, 50);
    }
  }

  function disconnect() {
    handler.current();
    cleanUp.current();
  }
}

/**
 * Do nothing
 */
function noop() {}

Enter fullscreen mode Exit fullscreen mode
Collapse
 
miketalbot profile image
Mike Talbot ⭐

Well we don't need it while the page is open, but its certainly possible it could survive a page navigation in an MPA. The url version survives well, but it would be neater.

Collapse
 
miketalbot profile image
Mike Talbot ⭐