Webページでユーザが読んでいるセクションのタイトルをヘッダーに表示したい(React)

はじめに

お久しぶりです。ネットワークデザインスタジオM2の及川です。
今回のテーマは、「Webページでユーザが読んでいるセクションのタイトルをヘッダーに表示したい(React)」です。
Webページ制作時に試行錯誤した結果たどり着いたコードを残したいと思います。開発はReact(Next.js)を使用しています。
また、公式があまり推奨していないものも使用しています。全く良いコードではないかと思います。「とりあえず動いた!」記録になりますのでご了承ください。

対象読者

  • HTML, CSS, JSがわかる
  • Reactはある程度わかる(詳しく触れません)
  • ヘッダー等にユーザが読んでいるセクションのタイトルを表示したい または、目次でユーザが読んでいるセクション箇所(現在地)を目立たせたい

想定されるWebページ

本実装が必要になったのは、以下のようなWebページ作成時です。

想定されるWebページの例
ヘッダーにはユーザの現在のセクションタイトルが表示されていて、そこをクリックすると目次がでてくるようなWebページや、目次がサイドバーとして表示されていて、ユーザの現在のセクションタイトルが目立つように表示されているようなWebページです。
学習系のサイトやブログなどで見ることが多いかと思います。
今回は目次の機能は省略し、ヘッダーにユーザの読んでいるセクションのタイトルを表示する、以下のような簡単なWebページを作成した時のコードについて書きたいと思います。

ざっくりまとめると、ステート関数でヘッダー内の文字を管理しようとしたら要らない再描画が起こってしまったのでrefを使って管理した!という内容です。

開発環境

  • React 18.2.0
  • React Intersection Observer
  • Tailwind CSS 3.3.2
  • Type Script 4.9.5

おおまかな構造

  • index.tsx
    • App.tsx
      • Headerコンポーネント
        ヘッダー内の文字列を指定する関数(handleHeaderText)を用意する。親(App.tsx)からも使用できるようにする。
      • MainContentsコンポーネント
        • Sectionコンポーネント
          React Intersection Observerでスクロールを管理する。セクションに入った/セクションから出た時に、Headerコンポーネント内で用意したhandleHeaderTextを用いてヘッダーの文字列を変更する。

最終的なコード

React Intersection Observerが必要となるため、インストールしてあります。

npm install react-intersection-observer

index.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { App } from "./App";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

こちらに関してはよくあるサンプルアプリケーションと同様のため、スキップします。

App.tsx

import { useRef } from "react";

import { Header } from "./components/Header";
import { MainContents } from "./components/MainContents";

const data = [
  {
    id: "apple",
    label: "はじめに",
    body: "顔は処の病気聴衆らからゴーシュが過ぎ小太鼓たた。ではいきなりくたくたましでにとって舞台でしまし。まじめたたんましもなくところが楽長の愉快げの所へはにわかに元気でうが、みんなまで東をしれものんまし。わからすぎ何も血を悪いならので途中の楽長のゴーシュ人にし第何先生がいのまねが叩くていんまし。",
  },
  {
    id: "banana",
    label: "第1章",
    body: "係りは楽隊をたいへんにやめが棒に孔のようがやってホールを見てどんなに皿を仕上げてやりだ。ぐるぐるおもわず風に小節がしんなら。みんなちょっとにセロにあけてむしをなっただ。一つにすましますなら。 「キャベジからくわえだろ。壁、やつをゴーシュ。入れ。」そこはたくさんのときのこれから前のときをきますなら。手は枝からすゴーシュをいっが孔で壁へしからどうか半分ひるられるますうちが出たで。まるでおねがいきいて、してなおりから来たて気持ちがでは拍子をそのまま時きいだまし。「ドアき。たばこに考えだ。いろわ。おれは何に狸を思えからじゃやめ音楽は黒い方たながらね。」それは愉快そうがひとべゴーシュドアにしとだ舞台のセロを弾いて弾いと落ちついててるです。かぶれははせてゴーシュに聞えるたな。誰もすっかりゴーシュは悪いどころでてホールもいきなり悪いのましまし。「こんどの半分の中から。なっ。」きみはいきなり下げなな。",
  },
  {
    id: "orange",
    label: "第2章",
    body: "マッチも下からあわせてひとつない。では一生けん命はぜひなるたらた。うるさい拍手だっと合わて行っからかっこうで怒るようた楽長をしてするとのそのそ下を番目叫びだた。またかとおっかさんはてやっとなっましたてありがたいものをは一生けん命は形のおいでたです。先生はあなたをさっきまし子のままおれにくわえないようになあタクトかっこうを口へおどすてそれかどなりんが睡ててましまし。「するとぎっしり一生けん命のあと。する。」あと許して飛びだしたかとはいりてなぜゴーシュを狸にどうぞなってこどもいじめなな。「愉快た。そのままきかせて行っまし。",
  },
  {
    id: "grape",
    label: "第3章",
    body: "こんなんは勢のゴーシュだんない。君がこのうまくつかれるたんが。係り。せいせいじゃあぱたっと狸一ぺんも広くのますなあ。ゴーシュをドレミファをやっていぼくらがこのかっこう虫かっかたりあと屋のホールなんての頭ゴーシュを結んてくださいたすっかりみんなのちがいもこう出ことな。用口さん。",
  },
  {
    id: "peach",
    label: "おわりに",
    body: "晩は寄りのかっか頭たちを曲がつけ楽長ないた。またまたまっ黒たでて風車ですなら。元気ましましもんたはだでは先生のまじめ汁のままへはすっかり生意気ただて、ぼくほど狸が出られるんましまし。もっすぎそれも扉をいいたとたくさんの扉の狩屋を聞える第三ろ汁のきょろきょろを見からいるなくだ。巻は毎日はいっでいだろ。本気は万落ち糸のようから聞いてしまうた。音もかっこうばかやどこと見ていで。セロはひもへあんまりとねむりからかっこうをいちどのようをあわてとゴーシュからわかってじっと子で叩くからやるまい。ぼろぼろじつにゴーシュを狸がききだた。みんなまたに呆気に困るて向うを来ですまし。ゴーシュが云いました。「こどもが出だ。棒、みんなをゴーシュ。叫び。」これはたくさんのままのすこしこんどのときをあれたます。頭はタクトにお晩をはいって野ねずみを狸が叫びてさっさとこんどあわてられるなかっ所へ云いたた。よほど病気ありて、こぼして見るてくださいんてあとをまたみみずくをいつも日ひいたませ。「壁い。からだを居りた。見るな。",
  },
];

type HeaderComponentType = {
  handleHeaderText: () => void;
};

export const App = () => {
  const headerRef = useRef<HeaderComponentType>(null);

  const headerTextRef = useRef<{ id: string; label: string }>({
    id: data[0].id,
    label: data[0].label,
  });

  const handleHeaderText = () => {
    if (headerRef.current) {
      headerRef.current.handleHeaderText();
    }
  };

  return (
    <>
      <Header headerTextRef={headerTextRef} ref={headerRef} />
      <MainContents
        data={data}
        headerTextRef={headerTextRef}
        handleHeaderText={handleHeaderText}
      />
    </>
  );
};

ここではまず、dataという変数名に対して本文を用意しています。セクションごとに、id、label(タイトル)、bodyを用意しています。
続いて、Headerコンポーネントで用意した関数を使えるように、HeaderコンポーネントへheaderRefを渡します。また、Headerコンポーネント内で描画する文字列をheaderTextRefで渡します。
行いたいことは、MainContents内で新しいセクションに入ったことが検知された時に、新しいセクションのタイトルをHeaderコンポーネントに渡すことです。
当初、ステート関数でheaderText, setHeaderTextを用意してそれぞれのコンポーネントに渡していたのですがうまくいきませんでした。ステート関数で管理した場合、HeaderだけでなくMainContentsも再描画されてしまうからです。今回のサンプルWebページでは文字のみのため特段問題ないのですが、MainContents内に画像や動画が入っていた場合、スクロールしてセクションを跨ぐたびに再描画される…という事態になってしまいます。それを防ぐためにrefで管理を行うことにしました。

Header.tsx

import { forwardRef, useImperativeHandle, useState } from "react";

type HeaderText = {
  id: string;
  label: string;
};

type Props = {
  headerTextRef: React.MutableRefObject<HeaderText>;
};

export const Header = forwardRef(({ headerTextRef }: Props, ref) => {
  const [headerText, setHeaderText] = useState<string>(
    headerTextRef.current.label
  );

  const handleHeaderText = () => {
    setHeaderText(headerTextRef.current.label);
  };

  useImperativeHandle(ref, () => {
    return {
      handleHeaderText: handleHeaderText,
    };
  });

  return (
    <header className="sticky top-0 h-20 flex items-center justify-center bg-slate-600">
      <p className="h-fit text-l text-center text-white font-bold">
        {headerText}
      </p>
    </header>
  );
});

ここでは、forwardRefを使用してhandleHeaderTextを親コンポーネントから使えるようにしています。ヘッダー内の文字列はここではセットしていません。

MainContents.tsx

import { Section } from "./Section";

type HeaderText = {
  id: string;
  label: string;
};

type Data = {
  id: string;
  label: string;
  body: string;
};

type Props = {
  headerTextRef: React.MutableRefObject<HeaderText>;
  data: Data[];
  handleHeaderText: () => void;
};

export const MainContents = ({
  headerTextRef,
  data,
  handleHeaderText,
}: Props) => {
  return (
    <div className="max-w-md py-10 flex flex-col gap-20 mx-auto mb-96">
      {data.map((item) => {
        return (
          <Section
            item={item}
            headerTextRef={headerTextRef}
            handleHeaderText={handleHeaderText}
            key={item.id}
          />
        );
      })}
    </div>
  );
};

ここはSectionコンポーネントを並べているだけなので説明を省きます。

Section.tsx

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

type HeaderText = {
  id: string;
  label: string;
};

type Item = {
  id: string;
  label: string;
  body: string;
};

type Props = {
  headerTextRef: React.MutableRefObject<HeaderText>;
  item: Item;
  handleHeaderText: () => void;
};

export const Section = ({
  headerTextRef,
  item,
  handleHeaderText,
}: Props) => {
  const ref = useRef<HTMLElement>(null);
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && entry.boundingClientRect.top < 90) {
            if (
              entry.target.querySelector("h2") === null ||
              entry.target.querySelector("h2")!.textContent === null
            )
              return;
            headerTextRef.current = {
              id: entry.target.id,
              label: entry.target.querySelector("h2")!.textContent!,
            };
            handleHeaderText();
          }
        });
      },
      {
        threshold: [0, 1],
        rootMargin: "-90px 0px -90px 0px",
      }
    );

    if (!ref.current) {
      return;
    }
    observer.observe(ref.current);

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    };
  }, []);

  return (
    <section ref={ref}>
      <h2 className="p-4 mb-4 text-2xl font-bold bg-slate-200">{item.label}</h2>
      <p className="leading-7">{item.body}</p>
    </section>
  );
};

ここでは、React Intersection Observerを使って、スクロールを監視しています。画面内に新しいセクションが入ってきた場合、Header.tsx -> App.tsx -> MainContents.tsx -> Section.tsxと渡ってきたhandleHeaderText関数によってヘッダー内の文字列を変更しています。

おわりに

今回記載したコードは推奨されていないものを使っていたり、ややバケツリレーのような形になっていたり、よろしくないコードかと思います。今後、より良いコードへアップデートしていきたいと思います。