紡Book の勉強ノートに戻る
技術ブログ2026.07.16

同じ写真が2回並ぶ — データの「単位」がズレると React の key が衝突する

ReactAPI設計紡Book

作品ページ作成ウィザードで出ていた Next.js のエラーを AI に直してもらい、その差分を検証したときの記録です。修正済み。

何が起きていたか

写真を選んで作品ページに仕立てるウィザード(通販の「カートに入れる → 住所 → 支払い」のように、1画面ずつ順に進めて完成させる形式)で、同じ写真が選択肢に2回並ぶことがあった。ブラウザの開発者ツール(コンソール=ページの裏でどんな警告が出ているかを見る画面)には、React の key 重複の警告。発生条件は「同じ写真に、言葉を2回以上綴っている」ことです。

原因: API と画面で「1件」の意味が違った

このアプリでは、1枚の写真に何度でも言葉を綴れます。データとしては「綴り(Meaning)」が写真とは別のレコード(表の1行)として積まれていきます。画面がサーバーにデータを取りに行く窓口を API と呼びます。

行は PostgreSQL の photos_meaning に残る。リストと選択肢はブラウザのメモリで、保存しない。PostgreSQLphotos_photo写真27・写真46photos_meaning綴りA 写真27 最初綴りB 写真27 新しい綴りC 写真46綴り1行が1件閉じても残る新着順届いた本文/api/memories/メモリ・保存しない綴りB 写真27綴りC 写真46綴りA 写真27綴り単位で3件同じ写真が2行あるここには残らないそのままウィザードの選択肢photoId で見分けるメモリ・保存しない写真27写真46写真27 もう一度同じ写真が2回並ぶkey が衝突する単位がズレた症状

ただしこの key 重複は、ここでは原因ではなく症状のほう。データが2件ある以上、key の付け方を工夫して警告だけ消しても、選択肢が二重に並ぶ問題は残ります。直す場所はリストを作る手前になります。

直し方: 写真単位に「畳む」

API 側を最初から写真単位で返す手もあります。そうしなかったのは、同じ /api/memories/ を読む画面が複数あって、畳み方がそれぞれ違うから。

畳みはブラウザのメモリ。PostgreSQL の photos_meaning は3行のまま残る。届いた本文 /api/memories/(メモリ・保存しない)綴りB 写真27 ・ 綴りC 写真46 ・ 綴りA 写真27棚(メモリ)写真27の背表紙に綴りAとBを積む履歴を残す。重ねた分は消さないウィザード(メモリ)写真27が1件・写真46が1件1枚=1件へ絞るPostgreSQL photos_meaning は3行のまま。画面が畳んでも表は消えない

API の段階で畳んでしまうと、棚が必要としている「重ねた分」まで消えます。だから API は綴り単位のまま返して、畳むのは各画面の持ち場、という分担にしてあります。

AI(この修正は Codex)が入れたのは、綴り単位のリストを写真単位に畳み込む関数だった。

ts
export function collapseMemoriesByPhoto(memories: FavoritePhoto[]): FavoritePhoto[] {
    // Map =「キー → 値」の対応表。ここでは photoId → collapsed の何番目か
    const indexByPhotoId = new Map<string, number>();
    const collapsed: FavoritePhoto[] = [];
 
    for (const memory of memories) {
        const index = indexByPhotoId.get(memory.photoId); // その写真は既に出たか(未登場なら undefined)
        if (index === undefined) {
            indexByPhotoId.set(memory.photoId, collapsed.length); // 初登場の写真は位置を記憶
            collapsed.push(memory);
            continue;
        }
        // 2回目以降は「最初の綴り」(writtenAt が最古)を代表に残す
        const current = collapsed[index];
        // 今の代表より古ければ差し替え。日付は「小さいほう=古いほう」で比べている
        if (memory.writtenAt && (!current.writtenAt || memory.writtenAt < current.writtenAt)) {
            collapsed[index] = memory;
        }
    }
    return collapsed; // 写真1枚につき1件だけの配列になって返る
}

なお引数の memories は綴り単位のリストで、型名の FavoritePhoto は「綴り1件」を表す型です。名前は写真寄りだが中身は綴り1件、というのがこの記事のズレそのものを体現しています。

畳み方は「最初に出てきた1件を残す」だけでは足りません。/api/memories/ は新着順で届くので、素朴に出現順で拾うと一番新しい綴りが代表になってしまいます。上のコードが writtenAt を比べる分岐を持っているのはこのためで、この比較を外すと「最初の綴りが代表」が「最新が代表」に静かに反転します。

Map に入れているのが綴りそのものではなく 位置(index) なのも理由があって、代表を後から差し替えても写真の並び(最初に現れた順)を崩さないためです。畳んだ結果が並び替わると、ウィザードの選択肢の順が毎回変わって見えます。

検証で一番気にした点: 「代表」を誰に合わせるか

畳むときに落とし穴があります。同じ写真に綴りが2つあるとき、どちらの言葉を代表にするか。ここを適当に決めると、ウィザードのプレビューに出る言葉と、保存後にサーバー側が解決する言葉が食い違う——「プレビューでは A の言葉だったのに、できた作品では B の言葉」という嫌なズレになります。

この修正はコメントで「backend の作品解決と揃えて最初の綴りを採用」と主張していた。ただコードのコメントは書いた側の言い分でしかないので、そこの裏取りは書いた Codex ではない別の AI に回した(書いた本人に検証させないのは、人間のコードレビューと同じ理由)。返ってきたのは、サーバー側の代表解決は綴りを id の小さい順に並べて先頭を取る実装だ、という結果だった。id は綴りが作られた順に振られる連番なので、これは実質「最初に綴った1件」を指します。

同じ写真の2綴り。畳んだ代表はメモリで保存しない。サーバは photos_meaning を id 昇順で読む。画面の畳みwrittenAt が最古代表は綴りA 最初メモリ・保存しない保存後の作品photos_meaning を id 昇順代表は綴りA 最初閉じても残る表を読むプレビューの言葉=保存後の言葉。どちらも最初の綴り

自分が見たのはその検証結果と差分で、取り込みはそこで判断した。

なお、同じ「id の小さい順で先頭」という決め方を別の API で明示し損ね、表示が揺れうる状態を作っていたことがレビューで見つかっている(「先頭の1件」は並び順を指定しないと毎回変わる — Django prefetch_related の落とし穴)。フロントの畳み込みとバックの代表解決は、同じ「最初に綴った1件」というルールを別の層で見ています。

学んだこと

  • 「同じデータ」でも単位(何をもって1件とするか)が違えば別物です。API の単位と画面の単位がズレたら、境界のどこかで明示的に変換します
  • リストを畳むときは「代表値の選び方」に必ず判断が入っています。その判断はサーバー側の解決ルールと揃えます——揃っているかはコメントを信じず実装を見ます
  • 届く順に暗黙に頼った畳み込みは、API の並びが変わった日に静かに壊れます。「最初の1件」が何を指すのかは、届いた順ではなくデータが持つ時刻で決めます

この「時刻で決める」は、時刻が同じ2件のところで止まっている。同じ画面にはもう1つ写真を表す型があって、そちらの代表の選び方と、ここの畳み込みの選び方が同時刻のときだけ分かれていた——その話は「同じデータを2つの型で持っていたら、代表の選び方まで2つに分かれていた」に書いた。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。