DEV Community

Cover image for How to load a #hash fragment to an anchor name in react (especially in first loading)
Alejandro Martinez
Alejandro Martinez

Posted on • Updated on

How to load a #hash fragment to an anchor name in react (especially in first loading)

How to implement URL hashes and scroll down to anchor name in react in the initial loading?

A hash fragment in the URL (i.e. www.mypage.com/article#fragment) to the anchor name is the value of either the name or id attribute when used in the context of anchors.

According to w3.org, it must observe two rules, Uniqueness: is said must be unique within a document, and String matching: Comparisons between fragment identifiers and anchor names must be done by exact (case-sensitive) match.

The id attribute may be used to create an anchor at the start tag of any element.

This example illustrates the use of the id attribute to position an anchor in an H2 element.

...later in the document
<H2 id="section2">Section Two</H2>
...
Enter fullscreen mode Exit fullscreen mode

In a simple HTML document it works on the loading perfectly since all the DOM are rendered on the browser, but normally in the first loading page in react we have just one div

...
<div id="root"></div>
...
Enter fullscreen mode Exit fullscreen mode

And if you try to access a section via #hash fragment (i.e. www.mypage.com/article#fragment) do not scroll to the desired section.

This behavior occurs for several reasons, one reason is because the anchor name offset is executed after the page loads the first DOM, and react does not yet inject the virtual DOM into the real DOM. Another reason is because the offset occurs before fetching the page content from an external API and has not yet loaded the components into the page (or using a skeleton load).

The solution to this problem is to make a manual process of the scroll obtaining the hash of the URL through the window.location and the eventListener 'hashchange' in case we want to keep the same behavior once the whole page has been loaded from the React components. Let's see the following hook that implements all this:

import { useEffect } from "react";

export function useHashFragment(offset = 0, trigger = true) {
  useEffect(() => {
    const scrollToHashElement = () => {
      const { hash } = window.location;
      const elementToScroll = document.getElementById(hash?.replace("#", ""));

      if (!elementToScroll) return;

      window.scrollTo({
        top: elementToScroll.offsetTop - offset,
        behavior: "smooth"
      });
    };

    if (!trigger) return;

    scrollToHashElement();
    window.addEventListener("hashchange", scrollToHashElement);
    return window.removeEventListener("hashchange", scrollToHashElement);
  }, [trigger]);
}
Enter fullscreen mode Exit fullscreen mode

The first param offset if we have a sticky menu on the top of the page, the second one is a trigger to determine when to execute the scroll down to the #hash fragment.

Without Images

If the document doesn't have any image that have to fetch for external link, you can use it like this:

import { useHashFragment } from "./hooks/useHashFragment";
import "./styles.css";

export default function App() {
  const sectionArrary = [1, 2, 3, 4, 5];
  useHashFragment();

  const handleOnClick = (hash: string) => {
    navigator.clipboard
      .writeText(`${window.location.origin}${window.location.pathname}#${hash}`)
      .then(() => {
        alert(
          `Link: ${window.location.origin}${window.location.pathname}#${hash}`
        );
      });
  };

  return (
    <div className="App">
      <h1>How to implement URL hashes and deep-link in react</h1>
      {sectionArrary.map((item) => (
        <section id={`section${item}`}>
          <h2>
            Title Section {item}{" "}
            <button onClick={() => handleOnClick(`section${item}`)}>
              copy link
            </button>
          </h2>
          <p>
            Lorem ipsum ...
          </p>
        </section>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Addional the handleOnClick catch the #hash-fragment from window.location of the anchor name/id defined in <section id="section3"> with the navigation.clipboard.writeText promise:

 const handleOnClick = (hash: string) => {
    navigator.clipboard
      .writeText(`${window.location.origin}${window.location.pathname}#${hash}`)
      .then(() => {
        alert(
          `Link: ${window.location.origin}${window.location.pathname}#${hash}`
        );
      });
  };
Enter fullscreen mode Exit fullscreen mode

Here you can check the demo whithout images.

Alt Text

With Images

One thing that can happen if we have <img/> tags with an external link, when scrolling to the named anchor before all the images are loaded, is that the scrolling fails because the size of the document is modified by the loaded images.

Alt Text

You can complement it with another hook about loading images hook and fix this problem.

Alt Text

If you like the article follow me in:

Discussion (2)

Collapse
lukeshiru profile image
LUKESHIRU • Edited on

This will not work, because you're removing a different listener that the one you added, and you're doing that once because your useEffect is not returning a cleanup function:

window.addEventListener("hashchange", () => scrollToHashElement());
return window.removeEventListener("hashchange", () => scrollToHashElement());
Enter fullscreen mode Exit fullscreen mode

Instead you should do this:

window.addEventListener("hashchange", scrollToHashElement);
return () => window.removeEventListener("hashchange", scrollToHashElement);
Enter fullscreen mode Exit fullscreen mode

One extra thing you could do is to have a useEventListener hook like this (I used JSDocs to make DX better):

/**
 * Hook for `addEventListener`/`removeEventListener`
 * 
 * @template {Element|Window} TargetElement
 * @param {TargetElement} element
 * @param {Parameters<TargetElement["addEventListener"]>[0]} eventName
 * @param {Parameters<TargetElement["addEventListener"]>[1]} listener
 * @param {import("react").DependencyList} [deps]
 */
export const useEventListener = (element, eventName, listener, deps) =>
    useEffect(() => {
        element.addEventListener(eventName, listener);
        return () => element.removeEventListener(eventName, listener);
    }, [element, eventName, listener, ...deps]);
Enter fullscreen mode Exit fullscreen mode

And you could keep splitting the functionality into more reusable functions, like this:

/** @returns {HTMLElement|undefined} */
export const getHashElement = () =>
    window.location.hash
        ? document.querySelector(window.location.hash) ?? undefined
        : undefined;

/** @param {number} offset */
export const scrollToHashElement = offset => {
    const elementToScroll = getHashElement();

    return elementToScroll
        ? window.scrollTo({
                top: elementToScroll.offsetTop - offset,
                behavior: "smooth"
          })
        : undefined;
};

export const useHashFragment = (offset = 0, trigger = true) =>
    useEventListener(
        window,
        "hashchange",
        () => (trigger ? scrollToHashElement(offset) : undefined),
        [offset, trigger]
    );
Enter fullscreen mode Exit fullscreen mode

That way you have useEventListener to add/remove vanilla event listeners to any element, getHashElement to get the element that matches the current hash, scrollToHashElement that scrolls to the hashed element if any, and finally useHashFragment that combines all that, but you can still use them separately if you want.

Cheers!

Collapse
alejomartinez8 profile image
Alejandro Martinez Author

Thanks for feedback!! Just to say, when I tried it on the project I don't why doesn´t work like

window.addEventListener("hashchange", scrollToHashElement);
return () => window.removeEventListener("hashchange", scrollToHashElement);
Enter fullscreen mode Exit fullscreen mode

But checking it on codeSandbox works perfectly, I changed it.