Fri Jun 10 2022

How to update URL hash on scroll with Next.js Router

An explanation of how to dynamically update the URL hash when scrolling in Next.js using next/router

Background

I have this feature on my website homepage where a user can click on a tag and they will be scrolled to that tag (see image below). This is done by making the tag a Next.js Link and ensuring its href matches the id of the element you want to scroll to.

Screenshot 2022-06-10 at 23.12.09

Problem

So, a user who landed on my homepage would be able to click on a tag and be scrolled to that section. The URL would also change and contain the hash value (so if a user clicked on the JavaScript tag, the URL would change to https://www.kishokanth.com/#javascript and he/she would be scrolled to that section).

The problem arose when the user would scroll down or up, to another section and then click on a blog post from one of those sections. After they finished reading and pressed the navigate back button, they would be scrolled to https://www.kishokanth.com/#javascript as the hash did not change when they were navigating away from the JavaScript section. This I felt warranted a fix as it's just bad UX.

What I thought was a quick fix turned out to be a little complicated and took me several attempts to get it right. Let's start from the initial attempt

Attempt 1

1import { useRouter } from "next/router";
2import { useEffect } from "react";
3import useGroupBy from "../../hooks/useGroupBy";
4import useRemoveHomeTag from "../../hooks/useRemoveHomeTag";
5import { ContentWrapper } from "../common/CommonStyles";
6import Card from "./Card/Card";
7import { BlogPostsWrapper, BlogPostSectionWrapper } from "./HomeStyles";
8import { HomeData } from "./interfaces";
9const Home = ({ data }: { data: HomeData }) => {
10 const [withoutHomeTag] = useRemoveHomeTag(data);
11 const [groupedByData] = useGroupBy(withoutHomeTag);
12 // ignore the above two hooks. Those are custom hooks I created
13 // to mould the data the way I wanted
14 const router = useRouter();
15 const handleHashChangeOnScroll = () => {
16 const sections = document.querySelectorAll("#section");
17 sections.forEach((section) => {
18 const rect = section.getBoundingClientRect();
19 if (rect.bottom > rect.height / 3 && rect.height > rect.bottom) {
20 router.replace(`/#${section.className}`, undefined, {
21 shallow: true,
22 });
23 router.beforePopState((state) => {
24 state.options.scroll = false;
25 return true;
26 });
27 }
28 });
29 };
30 useEffect(() => {
31 document.addEventListener("scroll", handleHashChangeOnScroll);
32 return () => {
33 document.removeEventListener("scroll", handleHashChangeOnScroll);
34 };
35 }, []);
36 return (
37 <>
38 <ContentWrapper>
39 <BlogPostSectionWrapper>
40 {Object.entries(groupedByData).map(([title, data]) => (
41 <section key={title} id="section" className={title}>
42 <h2 id={title}> # {title} </h2>
43 <BlogPostsWrapper>
44 {data.map((post) => (
45 <Card key={post.title} {...post} />
46 ))}
47 </BlogPostsWrapper>
48 </section>
49 ))}
50 </BlogPostSectionWrapper>
51 </ContentWrapper>
52 </>
53 );
54};
55export default Home;

Let's step through the code

At the top, we have the import statements, and my home component is receiving some data from the CMS. I am moulding this data into the shape I want with the custom hooks in lines 10 and 11. The method that handles the updating of the URL hash is called handleHashChangeOnScroll. This method is added to the page as the component mounts (line 31) and is removed when the component is unmounted (line 33). We will look into what's happening inside this method in a bit, but let's first look at the content that is being rendered in the render of this component.

Object.entries(groupedByData) (line 40) is basically an array which contains arrays of data about the section. Mapping over this basically produces several sections with a title and 3 blog post cards per section as shown below

Screenshot 2022-06-11 at 23.41.14

The important thing to note here is that we give an id of "section" to every section (line 41) as this is how we grab the nodes of these sections using document.querySelectorAll later on. Also, notice how we pass the title of the section as a className to it (line 41).

Now, let's move back to understanding what's going on inside the handleHashChangeOnScroll() method. This method is called when a user scrolls the page, and several things happen.

  1. We grab all the elements which contain the id "sections" (line 16). This returns a node list

  2. We loop through these elements with the forEach array method, and in this loop, we get information about the positioning of each element using a method called getBoundingClientRect(). This is a method that returns a DOMRect object which provides information about the size of an element and its position relative to the viewport. This is highly useful to us as we are able to know which element of the Section NodeList is visible on the screen. I use the information about the positioning of each element from the DOMRect object to know whether they are in the viewport, as that is when I want to change the hash in the URL.

  3. Since I wanted to change the hash as a user scrolled to the heading of a new section or 1/3 of a previous section, I added rect.bottom > rect.height / 3 && rect.height > rect.bottom as the condition to the if statement which handles the changing of the URL hash (line 19). You can of course change this depending on your needs.

  4. If any of the positional data of the elements meet the condition in the if statement, we do a shallow routing of the page, which is a next/router feature allowing us to change the hash in the URL (line 20-21). The hash that is passed to this shallow routing is obtained by the className of the section that met the if condition.

  5. The code in lines 23-26 is important as it prevents the default behaviour of scrolling to the top from next/router. So if a user was on https://www.kishokanth.com/#javascript when they left that page, they will be scrolled to the #javascript hash when they navigate back, and not to the top of the page.

Observations from attempt 1

Everything appeared to be working as it should as the URL hash was being changed when scrolling between sections. I did however notice that the handleHashChangeOnScroll() method was being called way too many times as the scroll events can fire at a high rate. So I needed to throttle this and did so as shown below.

Attempt 2

1import throttle from "lodash.throttle";
2import { useRouter } from "next/router";
3import { useEffect, useMemo } from "react";
4import useGroupBy from "../../hooks/useGroupBy";
5import useRemoveHomeTag from "../../hooks/useRemoveHomeTag";
6import { ContentWrapper } from "../common/CommonStyles";
7import Card from "./Card/Card";
8import { BlogPostsWrapper, BlogPostSectionWrapper } from "./HomeStyles";
9import { HomeData } from "./interfaces";
10const Home = ({ data }: { data: HomeData }) => {
11 const [withoutHomeTag] = useRemoveHomeTag(data);
12 const [groupedByData] = useGroupBy(withoutHomeTag);
13 // ignore the above two hooks. Those are custom hooks I created
14 // to mould the data the way I wanted
15 const router = useRouter();
16 const handleHashChangeOnScroll = () => {
17 const sections = document.querySelectorAll("#section");
18 sections.forEach((section) => {
19 const rect = section.getBoundingClientRect();
20 if (rect.bottom > rect.height / 3 && rect.height > rect.bottom) {
21 router.replace(`/#${section.className}`, undefined, {
22 shallow: true,
23 });
24 router.beforePopState((state) => {
25 state.options.scroll = false;
26 return true;
27 });
28 }
29 });
30 };
31 const throttledScrolleHandler = useMemo(() => {
32 return throttle(handleHashChangeOnScroll, 1500);
33 }, []);
34 useEffect(() => {
35 document.addEventListener("scroll", throttledScrolleHandler);
36 return () => {
37 document.removeEventListener("scroll", throttledScrolleHandler);
38 };
39 }, []);
40 return (
41 <>
42 <ContentWrapper>
43 <BlogPostSectionWrapper>
44 {Object.entries(groupedByData).map(([title, data]) => (
45 <section key={title} id="section" className={title}>
46 <h2 id={title}> # {title} </h2>
47 <BlogPostsWrapper>
48 {data.map((post) => (
49 <Card key={post.title} {...post} />
50 ))}
51 </BlogPostsWrapper>
52 </section>
53 ))}
54 </BlogPostSectionWrapper>
55 </ContentWrapper>
56 </>
57 );
58};
59export default Home;

The only changes in the above code from the code in attempt 1 are between lines 31-39. What's happening there is that I basically created a handler method to throttle the handleHashChangeOnScroll method every 1500ms. I then added this handler method to the addEventListener and removeEventListener.

Observations from attempt 2

The throttling seemed to have taken care of the issue where handleHashChangeOnScroll was being called numerous times, but I noticed that there was a different issue where the page was being rerendered every 1500ms (which is incidentally the throttling time). This one had me stumped but after reading some more next.js documentation, I realised that this rerendering was due to the shallow routing being performed. So if the rerendering was frequent, it means the shallow routing was being performed frequently. I did more digging and discovered that as long as the rect.bottom > rect.height /3 && rect.height > rect.bottom condition was true (and this would happen if the user would scroll to the top of a new section or is 1/3 of the way in the previous section), the shallow routing would change the hash (the same one numerous times), thereby causing rerenders. So I had to find a way to trigger the shallow routing only once as a new section met the conditions in the if statement. This brings me to the final attempt.

Attempt 3

1import { useRouter } from "next/router";
2import { useEffect, useMemo, useState } from "react";
3import useGroupBy from "../../hooks/useGroupBy";
4import useRemoveHomeTag from "../../hooks/useRemoveHomeTag";
5import { ContentWrapper } from "../common/CommonStyles";
6import Card from "./Card/Card";
7import throttle from "lodash.throttle";
8import { BlogPostsWrapper, BlogPostSectionWrapper } from "./HomeStyles";
9import { HomeData } from "./interfaces";
10const Home = ({ data }: { data: HomeData }) => {
11 const [withoutHomeTag] = useRemoveHomeTag(data);
12 const [groupedByData] = useGroupBy(withoutHomeTag);
13 const [hash, setHash] = useState("");
14 const router = useRouter();
15 const handleHashChangeOnScroll = () => {
16 const sections = document.querySelectorAll("#section");
17 sections.forEach((section) => {
18 const rect = section.getBoundingClientRect();
19 if (rect.bottom > rect.height / 3 && rect.height > rect.bottom) {
20 setHash(section.className);
21 router.beforePopState((state) => {
22 state.options.scroll = false;
23 return true;
24 });
25 }
26 });
27 };
28 const throttledScrolleHandler = useMemo(() => {
29 return throttle(handleHashChangeOnScroll, 1500);
30 }, []);
31 useEffect(() => {
32 document.addEventListener("scroll", throttledScrolleHandler);
33 return () => {
34 document.removeEventListener("scroll", throttledScrolleHandler);
35 };
36 }, []);
37 useEffect(() => {
38 router.replace(`/#${hash}`, undefined, {
39 shallow: true,
40 });
41 }, [hash]);
42 return (
43 <>
44 <ContentWrapper>
45 <BlogPostSectionWrapper>
46 {Object.entries(groupedByData).map(([title, data]) => (
47 <section key={title} id="section" className={title}>
48 <h2 id={title}># {title}</h2>
49 <BlogPostsWrapper>
50 {data.map((post) => (
51 <Card key={post.title} {...post} />
52 ))}
53 </BlogPostsWrapper>
54 </section>
55 ))}
56 </BlogPostSectionWrapper>
57 </ContentWrapper>
58 </>
59 );
60};
61export default Home;

I opted to use the useState and useEffect hooks to help me here. Notice the “state variable” called hash in line 13. You can see that in line 19, I am updating the value of hash with setHash with the className of the section whenever the if condition was met.

And in lines 37-41, I have a useEffect hook that is keeping an eye on the changes to the hash state, and runs the shallow routing whenever it changed.

This effectively prevents the issue we discovered after our attempt 2.

Conclusion

There you go folks, that's my attempt at implementing a feature that allows the next/router to update the hash of the URL as you scroll between sections. Admittedly, there might be better ways of doing this and if you know of any, do let me know!

If this post helped you, also let me know!

Here's a list of links that you might find useful to read in your quest to implement a feature such as the one we discussed.

  1. The getBoundingClientRect method

  2. More info about the next/router API

  3. next/router shallow routing

Comments