Delight your users with this instant scroll restoration custom hook
Tags: javascript, react, webdev, programming
Published: 2023-10-04 12:59:00

##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.


```js
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") ?? "{}");
}

```

### 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.

```js
export function useScrollRestoration(
  key = window.location.href,
  timeout = 500
) {
   // ... implementation
}
```

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.

```js
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
}
```

###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.

```js
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);
    }
  }
```

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.

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

## The Whole Thing

```js
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;
}

```

## Demo

{% codesandbox z4lykt %}

## 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)**

{% codesandbox n4dpo initialpath=/JJKaY4ERIzEIEWM_1YGfp/embed runonclick=0 %}