はじめに
こんにちは、ふくちと申します。
社内向けのWebアプリを作っていた時に、
- 一覧画面から詳細画面へ移動すると正常に見える
- ただ、同じ詳細画面のURLを新しいタブで開いたり、ブラウザをリロードしたりすると「見つかりません」と表示される
という事象にぶつかりました。
ということでこの事象をフロントエンドの1つ要素ずつ理解して、紐解いていこうと思います。
結論
詳細画面がブラウザの中に保存されている一覧データから対象を探してたことが原因でした。
元々の処理としては、以下のようになっていました。
ということで、一覧ページを開いたうえで詳細ページを開いた場合は問題なく表示されるのですが、いきなり詳細ページに飛ぶとデータが表示されない、という状況になっていました。
本来必要だったのは、次のようなフローです。
画面を開くと何が起きるのか
まず、Webアプリの部品を整理します。
| 用語 | 解説 |
|---|---|
| ブラウザ | ChromeやSafariなど、Webページを表示するアプリ |
| URL | ページの住所、/items/123のような形 |
| フロントエンド | ブラウザで動き、利用者が見る画面の部分 (利用者が見ている部分) |
| バックエンド | データベース検索や認証認可処理などを担当する、裏側の処理 (利用者には見えない部分) |
| API | フロントエンドがバックエンドに「このデータをください」 と依頼するための窓口(利用者には見えない部分) |
| データベース | データを長期間保存しておく場所 (利用者には見えない部分) |
一般的な詳細画面では、URLに含まれるIDを使ってAPIへ問い合わせ、データベースにある情報を取得します。
この流れなら、どのような形で詳細ページを開いても同じ結果になります。
データの正しい保管場所はバックエンド側であり、ブラウザの一時的な記憶を頼りにする処理になっていないからです。
SPAとは
今回の問題の背景には、SPA(Single Page Application)があります。
SPAは、画面を移動するたびにWebサイト全体を読み直すのではなく、最初に読み込んだJavaScriptで画面の一部だけを切り替える方式です。
SPAではJavaScriptがURLの変化を読み取り、どの画面を表示するかを決めます。従来のMPA(Multi Page Application)とはここの仕組みが違うようです。
MPAのようにURLごとに新しいHTMLをバックエンドで生成するわけではなく、必要最低限の変化させたい部分だけを画面側でHTML生成して更新する形がSPAです。
それゆえ、SPAは操作が軽快です。その一方で、ブラウザ内に持っている一時データ、つまりステート(state: 状態) が、画面の正しさに影響するため、そこを考える必要があります。
ステートとはブラウザ側で保持している情報です。たとえば、一覧画面を開いたあとなら、画面は次のようなステートを持つかもしれません。
ブラウザ内の状態
├─ 一覧データ: [A, B, C]
├─ ログイン中の利用者情報
└─ 画面の表示設定
ReactのようなUIライブラリでは、複数の画面で共有する状態をContextと呼ばれる仕組みで持たせることがあります。Contextは、アプリ全体で使える共有の入れ物などのイメージでしょうか。
今回はこのステートをContextで保持していました。ただし、このContextはブラウザのメモリ上にあります。新しいタブを開いたりリロードしたりすると、原則として空から始まります。
今回の当初の実装においては、一覧画面で最初にAPIから複数のデータを取得して、Contextに保存していました。
その後に詳細画面を開くとContextを検索し、画面を正しく描画することができました。
それゆえ、URL直リンクなどのステートおよびContextが存在していない状態で詳細ページにアクセスした場合、データが取得できないので「見つかりません」となっていました。
解決策
上記の問題を解決するために、Contextに詳細ページのデータが存在しないとき、追加でAPIコールをする処理を追加しました。
初期実装では、同期的なContext検索だけをしていました。ここで見つからなければ、その時点で「見つかりません」と画面に表示されます。
そこで検索に失敗した時点ではレスポンスを返さず、非同期でAPIの返答を待つ形にしました。
具体的な修正点は大きく2つです。
1. 詳細を1件だけ取得するAPI呼び出しを追加した
一覧全体を取り直すのではなく、URLに指定された対象だけをAPIで取得するようにしました。これは全件取得するのに加えて高速に、かつ必要最低限の情報だけを取得するようにしたかったからです。
取得したデータは、Contextの一覧データにも追加または更新(upsert)します。
upsertは「同じIDのデータがあれば更新し、なければ追加する」操作です。これにより、直リンクで取得したデータも、その後に開く画面から共有して使えます。
2. 画面の結果を4種類に分けた
初期実装ではContext上に見つからないだけでNot Foundを表示していました。修正後は次のように分離しています。
| 状態 | 意味 | 画面の表示 |
|---|---|---|
| 読み込み中 | APIへ問い合わせ中で まだ結果が返ってきていない |
ローディング表示 |
| 成功 | 対象データを取得できた | 詳細画面表示 |
| 未発見 | APIも対象なしと返した | 「見つかりません」 |
| 権限なし | データはあるが、閲覧が許可されない | 権限に関するエラー |
| 通信・その他の失敗 | ネットワークやサーバーで問題が起きた | 取得失敗のエラー |
未発見は対象が存在しないことを意味するHTTP/画面上の表現です。権限なしは存在するかどうかとは別に、その利用者が見てよいかというルールです。
エラー内容に応じてある程度正しい情報を表示することが大事です。
AWSなどでは、時に事実と異なるエラー内容を返す場合があります。これは正しいエラー情報を返してしまうとセキュリティ上のリスクに繋がる可能性があるらしく、それを回避するためにあえてそういう形にしているようです。
また、URLに含まれる親データのIDと、取得した詳細データの所属先が一致するかも確認します。これにより、別の親データのURLで意図しない詳細を表示することを防げます。
useEffectについて理解を深める
今回の修正では、詳細ページを表示した後に必要なデータがContextにないことを確認した後、Reactの useEffect を使って詳細取得を開始しています。
useEffectは、Reactが画面を描画した後に、Reactの外部にある仕組みと同期するための機能です。Reactではこのような処理を Effect と呼びます。APIへの通信、ブラウザAPIの利用、外部ライブラリとの接続などが代表例です。
今回の処理で言うと、次のようになります。
// Context内の一覧データを検索する
const item = findItemInContext(itemId);
// useEffectの中身は、Reactが画面を描画した後に実行される
useEffect(() => {
// URLにIDがなければ、どのデータを取得すべきか決められない
// itemがあれば、Contextに取得済みなのでAPIを呼ぶ必要がない
// どちらかに当てはまる場合は、ここでEffectを終了する
if (!itemId || item) return;
// APIの返答を待っている間に、利用者が別ページへ移動する可能性がある。
// その場合、古いAPIの返答で移動先の状態を更新しないために使う印。
let canceled = false;
// まだAPIの結果がないため、画面を「読み込み中」に切り替える
// この状態更新によってReactが再描画する
setLoadState('loading');
// この処理の内側で、IDを指定した詳細取得APIが呼ばれる
// fetchItemはPromiseを返すため、APIの完了を待たずに次へ進む
fetchItem(itemId)
// APIから正常に返答があった場合に実行される
.then((fetchedItem) => {
// APIの待機中にページを移動していたら、返答を無視する
if (canceled) return;
// データを取得できたらloaded、対象なしならnot-foundにする
setLoadState(fetchedItem ? 'loaded' : 'not-found');
})
// API通信やサーバー処理でエラーが起きた場合に実行される
.catch((error) => {
// すでにこのEffectが不要になっていたら、エラーも無視する
if (canceled) return;
// 画面をエラー状態に切り替える
setLoadState('error');
});
// ページ移動などしたらこのEffectが不要になったタイミングでReactが実行する。
return () => {
// 進行中のAPIから後で返答が来ても、上のthen/catchが状態を更新しないようにする
canceled = true;
};
// 依存配列。これらの値が前回の描画から変わったとき、Effectが再実行される
}, [fetchItem, item, itemId]);
このコードは、次の順番で動きます。
なぜ画面の描画中にAPIを呼ばないのか
Reactの描画処理は、現在の入力と状態から、どの画面を表示するかを計算する処理です。同じ入力なら同じ結果になることが求められ、API通信のように外部へ影響する処理を描画中に直接実行してはいけません。
useEffect に入れることで、まず現在の状態を使って画面を描画し、その後にAPIコールを実行できます。返答待ちの間は読み込み中を表示し、Contextが更新されたらReactが詳細ページを再描画します。
依存配列とは
useEffectの最後にある配列を依存配列と呼びます。
[fetchItem, item, itemId]
Reactは、前回の描画と比べてこの中の値が変わったときにEffectを再実行します。
例えばURLのIDが変われば、新しいIDに対応するデータが必要になるため、Effectが再実行されます。
一方、Contextに対象データが入った後は、Effect自体が再実行されても冒頭の条件で終了し、同じデータを取り直しません。
if (!itemId || item) return;
依存する値を配列から勝手に省くと、古いIDを参照したり、必要な取得が行われなかったりする原因になります。依存配列は「実行回数を調整するための裏技」ではなく、Effectが利用している値を正しく宣言するものです。
cleanupは古い通信結果による更新を防ぐ
Effectの中で返している関数をcleanup関数と呼びます。
cleanupは、そのEffectが不要になったときに実行されます。
例えば、APIの返答を待っている途中で別の詳細ページへ移動した場合です。
今回の一般化した例では canceled を true にし、古いAPIの返答が後から届いても画面の状態を更新しないようにしています。これは複数の通信結果が到着する順番によって表示が入れ替わる競合状態を防ぐためです。
useEffectを使う場面と使わない場面
Reactについて調べると、「useEffect はなるべく使わない」「Effectを減らすべき」という意見をたまに見かけます。
これがなぜなのか気になったので少し調査してみました。
結論としては、Effectが不要な処理にまで使わない方が良いよね、という意味のようです。多分。
React公式ドキュメントでは、Effectを「Reactのパラダイムから抜け出すための脱出手段」と説明しています。曰く、
外部システムが関与しない場合(例えば、プロパティやステートが変更されたときにコンポーネントの状態を更新したい場合など)、Effectは必要ありません。
不要なEffectを削除することで、コードの可読性が向上し、実行速度が速くなり、エラーも少なくなります。
つまり、Reactコードから抜け出して外部システムと同期するために使用するのがEffectであり、それ以外の場合には採用しない方が良いということのようです。
例えば、下記のような性と名からフルネームを作る処理。
既存のプロパティやステートから計算できるものはstateとして保持すべきではなく、代わりにレンダリングの過程で計算するようにしましょう、ということだそうです。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 冗長な状態管理と不要なEffectは避けるべき
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ✅ レンダリング時に計算することが推奨
const fullName = firstName + ' ' + lastName;
// ...
}
他にも、次のような処理にはEffectは不要なようです。
- 一覧データを条件で絞り込み、表示用の配列を作る
- ボタンが押されたときにフォームを送信する
- あるstateが変わったら、別の重複したstateへ同じ内容をコピーする
これらは描画中の計算や、クリックなどのイベント処理として書けます。Effectにすると余分な再描画や状態のずれが生まれやすくなるため、避けましょうということでした。
一方、今回のように「詳細ページURLのIDに対応するサーバー上のデータと画面を同期したい」という処理は、ネットワークというReact外部の仕組みを扱います。そのためuseEffectを使うこと自体は妥当と判断しました。
ただし、API取得をすべて手書きのEffectで実装するのが常に最善とは限りません。
React公式も、利用中のフレームワークにデータ取得機能がある場合はそれを使い、クライアント側ではルーターのデータ取得機能やキャッシュライブラリを検討するよう案内しています。
自前実装するとキャッシュ、重複リクエストの防止、事前取得、競合状態の処理などまで考える必要がありますが、便利なライブラリや機能があるならそっちを使う方が良いこともあるようです。
ここまでを踏まえて、判断基準は次のように整理できます。
| 処理 | useEffect | 主な置き場所 |
|---|---|---|
| プロパティやステートから 表示値を計算する |
不要 | 描画中に計算する |
| ボタンクリックで保存する | 不要 | イベントハンドラー |
| APIやブラウザAPIと画面を同期する | 使用候補 | Effectまたは専用のデータ取得機能 |
| 複数画面でサーバーデータをキャッシュする | 生のEffectだけでは不足しやすい | ルーター、フレームワーク、データ取得ライブラリ |
まとめ
この経験から得られる学びはこんなところでしょうか。
- Context(ブラウザのメモリ)はあくまで一時的な保存場所
- 直接URL・新しいタブ・リロードは、常に独立した入口として設計する
- 未取得・読み込み中・未発見・権限なし・通信失敗 などはそれぞれ別の状態として扱う
- 画面表示のために必要なデータは、必要になった画面自身が取得できるようにする
SPAでは、ブラウザのステートとContextを適切に扱う必要があるんだなと理解しました。シンプルで便利ながらも奥が深いなぁと…
結論、フロントエンドわっかんないよ!(以下ツイートが全部理解できるようになるまで頑張ってみたいと思います)