Reactにおけるページ遷移について

はじめに

TicketMeでエンジニアをしている坂西です。現在はチケット印刷機能の開発に携わっています。

React や Next.js でのページ遷移は、コンポーネントのライフサイクルに大きな影響を与えます。適切な方法を理解し、使い分けることが重要です。 実際のプロジェクトで発生した、サーバーサイドレンダリングからISRに移行した際に発生したクライアントサイドナビゲーションの問題について説明します。

経緯

当初、すべてのページでSSRが採用されており、ページ遷移はサーバーサイドナビゲーションでした。 利用者が増えるに伴い、負荷が増加したため、一部のページをSSRからISRに移行しました。 この移行によって、一部のページ遷移でクライアントサイドナビゲーションが行われるようになりました。

ただ、Chakra UIのLinkコンポーネントをそのまま使用していたため、フルページリロードが発生しており、当初は特に問題はありませんでした。

しかし、国際化対応(i18n)の実装を進める中で、Next.jsのページ遷移(SPA遷移)が必要になり、Linkコンポーネントに as={NextLink} を設定することになりました。 これにより、ページ遷移時にフルページリロードが行われるのではなく、部分的な更新が行われるようになりました。 その結果、ページ遷移時にコンポーネントが再レンダリングされず、データが更新されないといった不具合が発生しました。

原因

フルページリロードの場合は、すべてのコンポーネントがアンマウントされ、新しいページがロードされる際に再マウントされます。

一方で、クライアントサイドナビゲーションの場合、以前のページのコンポーネントはアンマウントされ、新しいページのコンポーネントがマウントされます。ただし、共通のレイアウトコンポーネント(たとえば、ナビゲーションバーやフッターなど)はアンマウントされず、そのまま残ります。また、共通のレイアウトコンポーネントだけでなく、ページ内部の一部コンポーネントもアンマウントされず、再レンダリングのみが行われることがあります。これにより、状態管理が期待通りに動作しないケースが発生することがあります。

具体的な例として、useState の初期値はコンポーネントの初回マウント時にのみ使用され、再レンダリング時にはReactが既存の状態値を保持するため、初期値は無視されます。 また、useEffect の依存配列が空配列 [] の場合、そのエフェクトはコンポーネントの初回マウント時にしか実行されません。

ページ遷移の方法が変わったことで、レンダリング時の挙動が変わり、元々の挙動では無くなってしまったのが原因でした。

具体的なコード

このコードは、Next.jsを使用して「/ticket/[id]」のような動的ルーティングを行い、チケット情報を表示するサンプルです。 Chakra UIのLinkコンポーネントとNext.jsのLinkコンポーネントを使い分けて、フルページリロードとSPAスタイルのページ遷移を比較しています。

// pages/ticket/[id].ts
import NextLink from "next/link";
import { useState } from "react";
import { Link } from "@chakra-ui/react";

type Ticket = { id: number; name: string };

const ticketData: Record<number, Ticket> = {
  1: { id: 1, name: "演劇" },
  2: { id: 2, name: "ミュージカル" },
  3: { id: 3, name: "コンサート" },
};

const TicketComponent = ({ id }: { id: string }) => {
  const [ticket, setTicket] = useState(ticketData[parseInt(id)]);
  const ids = ["1", "2", "3"];

  return (
    <>
      <div>
        <h1>チケット情報</h1>
        <p>ID: {ticket.id}</p>
        <p>イベント名: {ticket.name}</p>
      </div>

      <br />

      <div>
        <h2>フルページリロード遷移</h2>
        <ul>
          {ids.map((id) => (
            <li key={id}>
              <Link href={`/ticket/${id}`}>チケット {id} のページへ</Link>
            </li>
          ))}
        </ul>
      </div>

      <br />

      <div>
        <h2>SPA遷移</h2>
        <ul>
          {ids.map((id) => (
            <li key={id}>
              <Link as={NextLink} href={`/ticket/${id}`}>
                チケット {id} のページへ
              </Link>
            </li>
          ))}
        </ul>
      </div>
    </>
  );
};

export default function TicketPage({ id }: { id: string }) {
  return <TicketComponent id={id} />;
}

TicketPage.getInitialProps = ({ query }: { query: { id: string } }) => {
  return { id: query.id };
};

実際にクリックして遷移するとわかるのですが、フルページリロード遷移の場合、ページ遷移するたびにチケット情報が書き変わります。 一方で、SPA遷移した場合、ページ遷移してもチケット情報が書き変わりません。

対策

状態のリセットを行う方法として、key、useEffect と useMemoを使う方法があります。それぞれのメリット・デメリットを理解し、適切に使い分けることが重要です。

コンポーネントにkeyを付与する

Reactにおけるkeyの設定は、コンポーネントの再レンダリングや再マウントの挙動に大きな影響を与えます。keyは、Reactがレンダリングする際に、各要素を一意に識別するために使用されます。keyを設定すると、Reactは要素が変更されたか、追加されたか、削除されたかを効率的に判断できます。

コンポーネントのkeyが変更されると、Reactはそのコンポーネントを「新しいコンポーネント」として扱います。つまり、既存のコンポーネントをアンマウントし、新しいコンポーネントをマウントすることになります。これによって、そのコンポーネントが初期状態から再レンダリングされます。

TicketComponentに、下記のようにkeyを与えてページ遷移すると、SPA遷移であってもチケット情報が書き変わることがわかります。

export default function TicketPage({ id }: { id: string }) {
  return <TicketComponent id={id} key={id} />;
}

デメリットとしては、コンポーネントが再マウントされると、その内部のすべての子コンポーネントも再マウントされます。これにより、不要な再レンダリングやパフォーマンスの低下を招く可能性があります。また、コンポーネントのすべての状態がリセットされるため、リセットが必要ない部分の状態もクリアされてしまう可能性があります。

useEffectを使う

useEffectを使うと、コンポーネントの再マウントを避けつつ、特定の状態やロジックのみを更新できます。これにより、細かな制御が可能です。

TicketComponent内部に、useEffectを追加してページ遷移すると、SPA遷移であってもチケット情報が書き変わることがわかります。

  useEffect(() => {
    setTicket(ticketData[parseInt(id)]);
  }, [id]);

デメリットとしては、状態が複雑な場合や依存関係が多い場合、useEffect での更新が意図しないタイミングで発生することがあり、結果として状態が不整合になる可能性があります。 また、レンダリングが2回行われてしまう問題があります。

useMemoを使う

適切かどうかは別として、useMemoを使うと、期待するページ遷移を行うことができます。

const [ticket, setTicket] = useState(ticketData[parseInt(id)]);

ここを下記のように書き換えてページ遷移すると、SPA遷移であってもチケット情報が書き変わることがわかります。

  const ticket = useMemo(() => ticketData[parseInt(id)], [id]);

大きな問題としては、useEffect や useState を使うべきところで useMemo を誤用すると、期待通りの再レンダリングが発生せず、バグの原因になりやすいです。これは、それぞれのフックが異なる発火タイミングと目的を持っているためです。

これらの違いを理解して適切に使い分けることが重要ですが、長期的なプロジェクト運用を考えると、useMemo の積極的な利用は保守性の観点から避けるべきだと感じています。

おわりに

クライアントサイドナビゲーションを行うと、一部のコンポーネントはアンマウントされず、状態が保持されたままになります。これが、期待通りに動作しない原因となることがあります。 その対策として、key、useEffect、useMemoを使う方法を整理しました。長期的なプロジェクトの保守性を考えると、useMemo の誤用を避け、keyによる再マウント、useEffect や useState の適切な使用が必要だと思いました。

エンジニア募集中!

私たちのチームでは、共にプロダクトを成長させるエンジニアを募集しています。最先端の技術を駆使し、ユーザー体験を向上させることに情熱を持っている方、ぜひ一緒に働きましょう!

詳細は下記リンクよりご覧いただけます。カジュアルな面談も随時受け付けていますので、お気軽にお問い合わせください。

ticketme.notion.site