DEV Community

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

Posted on • Updated on

Delight your users with this instant scroll restoration custom hook

TL;DR

  • In a multipage application, pressing the back button should restore the scrolling position of the previous page so that the user doesn't lose context.
  • The scroll position shouldn't glitch at all, it should be as if they never left.
  • The custom hook at the end of this article allows this in just a few lines of code.
  • The rest of this article describes how it works.
  • Thanks to some of the feedback this now works in browsers with privacy turned on

The Problem

There is a UX problem in some multipage React apps. A user scrolls down a long page and finds a link to click which opens a new page; then they press the back button on the new page and find themselves back at the top of the list, struggling to find their previous position manually.

The Solution

The solution is conceptually simple, store where the container was scrolled and restore that position when the previous page is reloaded.

The devil is in the details though. When is it time to restore the position? Loading the old page may not immediately fill the container with all of the content - perhaps it takes a few cycles for it to be built or perhaps a server call is required.

useScrollRestoration hook

To solve this problem I've built a custom hook that is easy to attach to a scrollable container and can even be used to remember multiple scroll positions for multiple lists.

The Concept

The concept of the useScrollRestoration hook is to provide a function that can be attached to the ref of the scrollable container and use this function to:

  • Listen for scroll events and store the scrollTop and scrollLeft positions
  • Attach a ResizeObserver and immediately restore the scroll positions when the content is large enough to enable the values to be restored.

This second point is the most important, unlike setTimeout or useLayoutEffect approaches, there is no glitch if the content isn't ready.

Building the Hook

Here's a walkthrough of how the hook works.

Storage

Firstly we need a place to store the scroll positions - these need to be persisted in sessionStorage in case we aren't using client-side routing and need to restore it on a page refresh. We also want to use a search parameter on the URL if the sessionStorage is not available due to privacy settings.

const KEY = "useScrollRestoration-store";

let scrolls;
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") ?? "{}");
}

Enter fullscreen mode Exit fullscreen mode

The ref function

Next we need to create the actual restoration function. I'm going to give it a key parameter for which scroll to store and restore - this will default to the current url (cleaned up to remove any scroll information) - and an optional timeout to abort restoring the scroll position if it's taking too long, which defaults to 1/2 a second.

export function useScrollRestoration(
  key = window.location.href,
  timeout = 500
) {
   // ... implementation
}
Enter fullscreen mode Exit fullscreen mode

The next thing to do is to return something we can put as the ref of the scrolling container. In this case I'm going to supply a function that will be called by React when the element is mounted. We also have a useEffect, this needs to return a function to disconnect handlers on unmount, however, it also needs to reapply the connection to the scrollable element in debug and strict mode as React will call it twice and it will only attach the ref once.

export function useScrollRestoration(
  key = window.location.href,
  timeout = 500
) {
  // ... other variables
  const connectRef = useCallback(connect, [key, timeout]);
  const tracked = useRef();
  useEffect(() => {
    if (tracked.current) {
      connectRef(tracked.current);
    }
    return disconnect;
  }, [connectRef]);
  return connectRef;

   // ... functions
}
Enter fullscreen mode Exit fullscreen mode

Connecting to a scrollable element

Ok so now we are into the meat of it. The connect function is responsible for adding the scroll event listeners, storing the scroll position AND restoring the scroll position when the content is big enough to accommodate it.

function connect(ref) {
    tracked.current = ref;
    disconnect();
    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;
        };
      }
    }

    function store() {
      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);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Firstly we disconnect any formerly connected events etc, then we check if this is React setting the reference (ref is truthy), and if it is we:

  • Attach a scroll listener
  • Set a detach function for the scroll listener into a ref called handler
  • Get scroll info for the current key if it exists

If we have scroll info then we are going to try to restore the scroll position. We do this by immediately setting it, in case the element is already full, and by waiting for the container to become the right size, using a ResizeObserver - it's this which really means that it scrolls without glitching.

We also add a cleanUp function that detaches the observer and we call this when we successfully get the right size, or after the timeout period.

Finally, in the scroll handler, we store the scroll position as the user scrolls the container element and then we write it to sessionStorage or add it to the URL after a 50ms debounce.

Disconnecting the handlers

Disconnection is easy, we call our ref stored clean-up functions.

  function disconnect() {
    handler.current();
    cleanUp.current();
  }
Enter fullscreen mode Exit fullscreen mode

The Whole Thing

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

const KEY = "useScrollRestoration-store";

let scrolls;
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") ?? "{}");
}

/**
 * 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 = 1500
) {
  key = removeScrollParameter(key);

  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 = 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;
        };
      }
    }

    function store() {
      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);
    }
  }

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

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

/**
 * Remove the scroll info from the URL
 */
function removeScrollParameter(href) {
  href = href.replace(/__scrollInfo=[^&]+/gi, "");
  if (href.endsWith("/?")) return href.slice(0, -2);
  if (href.endsWith("/")) return href.slice(0, -1);
  return href;
}

Enter fullscreen mode Exit fullscreen mode

Demo

Conclusion

I hope you've found this an interesting exploration into how we can use the features of refs as functions to create dynamic patterns for DOM element events and interactions. Feel free to use the hook wherever you like.

MIT License (c) 2023 Mike Talbot (miketalbot)

Top comments (13)

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 ⭐