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
andscrollLeft
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") ?? "{}");
}
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
}
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
}
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);
}
}
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();
}
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;
}
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)
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.
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.
@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...
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...
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
I'll give it a go and post a follow up.
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.
That's amazing thanks! 🤩
Here's a TypeScript version if you prefer:
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
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:
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.