コムセント 技術情報

  1. TOP
  2. コムセント 技術情報
  3. React Router v7 で画面遷移時ローディングアニメーションを実装する

React Router v7 で画面遷移時ローディングアニメーションを実装する

前置き

僕は長らく Vue.js と相思相愛・蜜月の関係にありましたが、この度 React に堂々と浮気することにしました。

世のお叱りを承知の上で言い訳させてもらいますが、Web エンジニアほど浮気を推奨されている職業は無いかと思われます。

客観的な評価が難しい Web エンジニアにとって、それまでどのようなライブラリ、アーキテクチャ、ワークフローを経験してきたかは、その人の価値を示す重要な要素となるでしょう。

しかも、「過去にお付き合いしていた」という経験より、「現在進行形で複数のライブラリとお付き合いしている」、つまり浮気しているとより人材としての評価が高い傾向にあることは疑いようもありません。実に邪悪ですね。

さて、弊社では長らく、やや複雑な UI や SPA 的なふるまいの Web システムが必要な場合は Vue.js を採用していました。

その影響もあり、当然僕も Vue.js に慣れ親しんでいたのですが、一度 Vue.js で大抵のモノを作れるようになると、それ以外のライブラリやフレームワークを勉強する優先順位は低くなりがちでした。

そのままズルズルと Vue.js 一本足打法で糊口を凌いでいたのですが、React は既に業界のディファクトスタンダートと言って過言でない市民権を得ているようです。

正直、「JSX とかいう一種の冒涜感を感じさせる奇怪な記法と、秒で定番が変わる周辺エコシステム」というネガティブイメージがあったことは否めないのですが、これだけ流行っているのはそれらが誤解、または事実であっても、それを補って余りあるメリットがあるからだろうと考え、React に手を出すことにしました。

ただし、直近の案件で React を使う予定はないので、とりあえず個人的に使用している Web システムのフロントエンドを React で書いてみることにしました。

初めて React を書いてみて半日で JSX への嫌悪感は好感に反転したので、己のチョロさと Vite のビルドエラーに頭を抱えもしましたが、もともと Vue.js に慣れていたこともあり、さほど苦労せずに開発は進みました。

今回はそんな開発の中で意外と苦労した、ローディングアニメーションの実装について書いていきます。

今回解説・作成するもの

完成系はこちらです。果物がまぶしいですね。スマホにすら対応していませんが、あくまで主眼は React Router なのでご容赦ください。

https://go-noji.github.io/react-router-7-loading-animation-demo-

GitHub リポジトリはこちらとなります。

https://github.com/Go-Noji/react-router-7-loading-animation-demo-

画面遷移ごとに画面を覆うアニメーションが表示されます。中央から円形に広がるアニメーションですが、円の中には遷移先の果物が表示されます。

イメージを大事にしているサイトではこのようなローディングアニメーションはよく目にするでしょう。

ただし、このアニメーションはいざ開発するとやや面倒な仕様であることに気が付きます。

このアニメーションの何が難しいのか

普通にローディングアニメーションを作る場合は、既に React Router のドキュメントにサンプルが載っているので、それを参考にすれば問題なく実装できるでしょう。

https://reactrouter.com/tutorials/address-book#adding-search-spinner

しかし、先に挙げたようなアニメーションを実装する場合はこのサンプルとは全く違ったアプローチが必要となります。

このアニメーションの実装にあたり課題となる点を列挙すると以下のようになります。

  • ・画面を覆いつくすまでに時間差があるため、裏の画面が見えてしまう
  • ・遷移先に表示される果物を事前に知っている必要がある
  • ・ローディング表示を解除するタイミングを適切に設定する必要がある

それぞれ解説します。

画面を覆いつくすまでに時間差があるため、裏の画面が見えてしまう

React Router では画面遷移の際、移動元のコンポーネントは画面から瞬時に消えてしまい、移動先のコンポ―ネントは loader or clientLoader が解決されるまで表示されません。

このアニメーションは画面を覆いつくすように表示されるため、画面が覆われるまでに時間差が生じます。つまり、アニメーションが開始され、画面が覆いつくされるまでの間、裏の画面が見えてしまうのです。

この裏に見えている画面を自然に見せたい場合、少なくとも画面をアニメーションが覆いつくすまでの間は移動元の画面を表示し続ける必要があるでしょう。

これを解決するためには、画面がアニメーションで覆いつくされたまで画面遷移タイミングを遅延させる必要があります。

しかも、画面遷移が起こるトリガーはユーザーによる Link のクリックだけではありません。ブラウザの戻る・進むボタンにも対応が必要です。初期描画時もアニメーションを表示させる場合は対応が必要でしょう。

遷移先に表示される果物を事前に知っている必要がある

このアニメーションは遷移先の画面に表示される果物を元にアニメーションを作成しています。中央に遷移先の果物が表示され、背景色はその果物の色になっています。

そのため、遷移先の画面を表示する前に、遷移先の果物がなんであるかを取得しておく必要があります。

React Router では遷移先の URL パラメータから必要な情報を取得・整形するための loader or clientLoader が用意されているので、これを利用するのが定番でしょう。

しかし、 loader or clientLoader は遷移先のコンポーネントが表示される前に実行されます。

先述した通り、遷移先のコンポーネントに移動すると遷移元のコンポーネントは画面から消えてしまいます。ひとつ前の課題をクリアするためには遷移元のコンポ―ネントを表示し続けたままアニメ―ションを開始する必要があるため、 loader or clientLoader が実行されるタイミングでは遅いのです。

よって、遷移先の果物を取得するタイミングはアニメーションを開始する前、すなわち遷移元のコンポーネントがまだ描画されているタイミングとなります。

ローディング表示を解除するタイミングを適切に設定する必要がある

さらに、アニメーションの開始タイミングだけではなく、終了タイミングも勘案する必要があります。

終了アニメーションは画面を覆ったローディング表示が徐々に透明になっていくものです。

つまり、この透明化アニメーションが開始する時点で、遷移先のコンポ―ネント描画が終わっている必要があります。動的にサーバーから最新情報を得る必要がある場合は、それら情報取得タイミング図って透明化アニメーションを開始する必要があるでしょう。

これらの課題をクリアするための方法を、実例のコードと一緒に解説していきます。

コード解説

実際のコードは上記 GitHub リポジトリで確認できますが、ここでは重要な部分を抜粋して解説します。

ちなみに今回の例では果物の情報が静的に用意されているため、API からデータを取得するような処理は存在していません。 しかし、実際のプロジェクトでは外部の API やサーバーからデータを取得することが多いかと思われますので、そのような部分では疑似的に遅延を発生させることによって、果物の情報を取得する処理を模倣しています。

遷移(ナビゲーション)を本来のタイミングから遅延させる。

まずはリンクのクリックやブラウザの戻る・進むボタンによる遷移について解説しましょう。

今回の要件ではローディングアニメーションの中に遷移先の果物が表示されるので、実際に遷移を始める前に果物の情報を取得する時間が必要です。

さらに、実際にローディングアニメーションが始まってから終わるまでの時間も必要となります。この二つの時間を合わせたものが、本体の遷移から遅延させなければならない時間となります。

さて、実際に遷移を遅延させるためにはどうすればよいでしょうか。

<Link> などのコンポーネントに代表される、ユーザのクリックなどをトリガーとする遷移だけを考えれば、<Link> などをラップした(ように見える)コンポーネントを作成し、そのコンポーネント内で遷移を遅延させることで実現可能です。 具体的には e.preventDefault() でデフォルトの遷移をキャンセルし、任意の時間を setTimeout などで遅延させた後に useNavigation を用いて遷移を行えば良いでしょう。

import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router';

// ページ遷移を遅延させるリンク
export default function DelayLink({to, children, delay = 1500, className = '', style = {}, immediateOnClick = () => {}}: {
to: string | number,
children: React.ReactNode,
delay?: number,
className?: string,
style?: Object,
immediateOnClick?: (e: any) => void,
}) {
// ページ遷移を行うためのフック
const navigate = useNavigate();

// 遅延遷移を行うための関数
const delayedNavigate = (path: string | number) => setTimeout(() => typeof path === "number" ? navigate(path) : navigate(path), delay);

// 現在 Ctrl キーが押されているか
const [isCtrlPressed, setCtlPressed] = useState(false);

// キーイベントを検知して Ctrl キーが押されているかを判定
const handleKeyEvent = (e: KeyboardEvent) => e.ctrlKey && setCtlPressed(true);

// キーイベントを検知する
useEffect(() => {
window.addEventListener('keydown', handleKeyEvent);
window.addEventListener('keyup', () => setCtlPressed(false));
return () => {
window.removeEventListener('keydown', handleKeyEvent);
window.removeEventListener('keyup', () => setCtlPressed(false));
};
}, []);

return typeof to === 'string' ? (
<a
href={to}
className={className}
style={style}
onClick={(e) => {
// 即時実行する関数を実行
immediateOnClick(e);

// Ctrl キーが押されている場合は通常の遷移を行う
if (isCtrlPressed) {
return;
};

// デフォルトのイベントをキャンセル
e.preventDefault();

// 遅延遷移を行う
delayedNavigate(to);
}}
>{children}</a>
) : (
<button
className={className}
style={style}
onClick={(e) => immediateOnClick(e), delayedNavigate(to)}
>{children}</button>
);
}

上記は <Link> を遅延実行できるようにシュミレーションしたコンポーネントです。

このコンポーネントを用いて遷移を行うことで、遷移を遅延させることができます。

しかし、今回のケースではユーザのクリックだけでなく、ブラウザの戻る・進むボタンによる遷移も考慮する必要があります(僕はこの事実に後から気が付いたため、上記コードはお蔵入りとなりました)。

直感的には useEffect を用いて popstate イベントを監視し、遷移タイミングを遅らせることができれば……と思うかもしれません。

実際には React Router 側で popstate イベントへハンドリングされている処理に開発者側コードが干渉できないため、この方法は使えません。

そこで、遷移をブロックする目的で使用される useBlocker を用います。React Router による画面遷移を一旦全てブロックし、任意の時間遅延させてから遷移を再開する、といったアプローチを取ることにします。

  // ナビゲーション先の情報を解決する非同期関数を同期させるためのステート
const [resolvedFunction, setResolvedFunction] = useState<Promise<void>>(Promise.resolve());

// 遅延実行を実現するため、一旦ナビゲーションをブロックするためのフックを用意
const blocker = useBlocker(({nextLocation}) => {
// ナビゲーション先のパス名を元に、ローディング状態を変更する
setResolvedFunction(setHandler(nextLocation.pathname));

// 全てブロックする
return true;
});

// ナビゲーション先の情報を解決されるのを待ってから、上記 useBlocker で一旦ブロックしたナビゲーションを遅延実行
useEffect(() => {
// ブロックされていなければ、何もしない
if (blocker.state !== 'blocked') {
return;
}

// ブロックされている場合、ナビゲーション先の情報取得終了を待ってから、アニメーションを開始する
(async () => {
// ナビゲーション先の情報取得終了を待つ
await resolvedFunction;

// アニメーションを開始させる
setLoadingStatus('loading');

// アニメーション終了状態を false に変更
setAnimationEnd(false);

// 遷移を再実行
blocker.proceed();
})();
}, [() => blocker.state]);

上記は、ナビゲーションをブロックするための useBlocker を用いて、ナビゲーションを遅延させるコードです。

useBlocker に関数を渡した場合、その関数で true を返すとナビゲーションがブロックされます。

useBlocker 事態の返値にはブロック状況を示すオブジェクトが入っていますが、 state プロパティが 'blocked' である場合に限り、ブロックしたナビゲーションを再度実行するための proceed 関数が実行可能となります。 state の状態を useEffect で監視しつつ、 'blocked' になった場合に setTimeout で遅延の後 proceed 関数でナビゲーションを実行します。

遷移先の情報を取得する

遷移の遅延は上記の方法で可能なので、次に遷移先の情報を取得する方法を解説します。

とはいっても、どのような情報が必要かはプロジェクトによって変動すると思いますので、今回の例ではローディングアニメーションに関する処理は useLoadingState.ts、 描画に必要な情報を取得する処理は useFruit.ts として分離しています。

とはいえ、事前情報の取得はローディングの開始タイミングで行う必要があるため、useLoadingState.ts の中で行われます。

そこで、useLoadingState.ts の引数にローディング開始時に実行される情報取得関数を渡すことにしました。

export function useLoadingStatus(setHandler: (pathname: string) => Promise<void>) {
// 略
}

この setHandler は useFruit.ts から提供することで、

  1. 1. useLoadingState.ts でローディングアニメーション開始直前で遷移先 URL を setHandler の引数に渡しつつ実行。この完了を await で待つ。
  2. 2. useFruit.ts で遷移先 URL を元に果物の情報を取得し、それを内部のステートに蓄え、ローディングアニメーションコンポ―ネントに提供。
  3. 3. useLoadingState.ts で setHandler の解決後、ローディングアニメーションを開始する。

といった流れを実現しています。

ローディング表示を解除するタイミングを適切に設定する

ローディング表示を解除するタイミングは、遷移先の情報取得が完了した後である必要があります。

今回の例ではローディング表示は useLoadingState.ts の中で管理されている loadingStatus というステートによって管理されており、コンポーネントでこのステートごとに表示を分岐することで実現しています。

開始タイミングは useLoadingState.ts 内部で適切に処理されていますが、終了タイミングは各コンポーネントから命令的に行う必要があります。

export const useLoadingControl = () => {
// LoadingStatusContext を取得
const [, setLoadingStatus] = useContext(LoadingStatusContext);

// ページ遷移を検知(同じ URL だとしても)するため、 useNavigate を使う
const location = useLocation();

// ローディングを完了させる
const completeLoading = () => setLoadingStatus('ready');

// ページ遷移を元にローディング完了させる
useEffect(completeLoading, [location.key, location.pathname]);

// 初回レンダリング時にローディングを完了させる
useEffect(completeLoading, []);

// メソッドの提供
return {completeLoading};
};

上記の useLoadingControl は、ローディング表示を解除するためのフックです。これを各コンポーネントで使用することで、遷移先の情報取得が完了したタイミングでローディング表示を解除することができます。ちなみに completeLoading が提供されていますが、

  • ・ページ遷移が発生した場合
  • ・初回レンダリング時

には useEffect を用いて自動的にローディング表示が解除されるようになっています。なので、多くの場合はこのフックを呼び出すだけで十分でしょう。

まとめ

今回は React Router v7 でローディングアニメーションを実装する方法について解説しました。

React Router の各種ローダーがが大変便利ですが、このようなエッジケースに対応するためには、それらを使わずに独自の方法で遷移を制御する必要があります。

個人的には遷移の遅延方法を模索し、 useBlocker に行きつくまで時間がかかりましたが、おかげで普通にチュートリアルなどを進めるよりも深い知見が得られたと思います。 Chat GPT や GitHub Copilot には随分助けられました。もちろん公式ドキュメントにも。

苦労はした部分はあったものの React Router での開発体験は期待以上のものでした。ライブラリとしてだけでなくフレームワークとして、面倒なローディングやフォーム操作を効率的に行える仕組みは開発していて心地よかったです。

Vue.js にも同様のフレームワークが存在しますが、食わず嫌いは良くありませんね。Vue.js とも良い関係は続けていきたいですが、これからは React とも積極的に仲良くならねばなるまいと痛感しました。

よってこれからは強く正しいエンジニアを目指し、どんどん新しい技術に浮気していく所存です。実に邪悪ですね。

プログラマー/N.Go

CodeIgniterやLaravel、Vue.jsといったフレームワークを用い、ECシステム、リアルタイム課金制生配信、掲示板ライクなSNSシステムなどのWebシステム制作に携わる。 プロジェクトによってはフロントエンドも一貫して請け負う。

このメンバーの記事一覧へ

おすすめ記事