作品ページ作成ウィザードで出ていた Next.js のエラーを AI に直してもらい、その差分を検証したときの記録です。修正済み。
何が起きていたか
写真を選んで作品ページに仕立てるウィザード(通販の「カートに入れる → 住所 → 支払い」のように、1画面ずつ順に進めて完成させる形式)で、同じ写真が選択肢に2回並ぶことがあった。ブラウザの開発者ツール(コンソール=ページの裏でどんな警告が出ているかを見る画面)には、React の key 重複の警告。発生条件は「同じ写真に、言葉を2回以上綴っている」ことです。
原因: API と画面で「1件」の意味が違った
このアプリでは、1枚の写真に何度でも言葉を綴れます。データとしては「綴り(Meaning)」が写真とは別のレコード(表の1行)として積まれていきます。画面がサーバーにデータを取りに行く窓口を API と呼びます。
ただしこの key 重複は、ここでは原因ではなく症状のほう。データが2件ある以上、key の付け方を工夫して警告だけ消しても、選択肢が二重に並ぶ問題は残ります。直す場所はリストを作る手前になります。
直し方: 写真単位に「畳む」
API 側を最初から写真単位で返す手もあります。そうしなかったのは、同じ /api/memories/ を読む画面が複数あって、畳み方がそれぞれ違うから。
API の段階で畳んでしまうと、棚が必要としている「重ねた分」まで消えます。だから API は綴り単位のまま返して、畳むのは各画面の持ち場、という分担にしてあります。
AI(この修正は Codex)が入れたのは、綴り単位のリストを写真単位に畳み込む関数だった。
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件」を指します。
自分が見たのはその検証結果と差分で、取り込みはそこで判断した。
なお、同じ「id の小さい順で先頭」という決め方を別の API で明示し損ね、表示が揺れうる状態を作っていたことがレビューで見つかっている(「先頭の1件」は並び順を指定しないと毎回変わる — Django prefetch_related の落とし穴)。フロントの畳み込みとバックの代表解決は、同じ「最初に綴った1件」というルールを別の層で見ています。
学んだこと
- 「同じデータ」でも単位(何をもって1件とするか)が違えば別物です。API の単位と画面の単位がズレたら、境界のどこかで明示的に変換します
- リストを畳むときは「代表値の選び方」に必ず判断が入っています。その判断はサーバー側の解決ルールと揃えます——揃っているかはコメントを信じず実装を見ます
- 届く順に暗黙に頼った畳み込みは、API の並びが変わった日に静かに壊れます。「最初の1件」が何を指すのかは、届いた順ではなくデータが持つ時刻で決めます
この「時刻で決める」は、時刻が同じ2件のところで止まっている。同じ画面にはもう1つ写真を表す型があって、そちらの代表の選び方と、ここの畳み込みの選び方が同時刻のときだけ分かれていた——その話は「同じデータを2つの型で持っていたら、代表の選び方まで2つに分かれていた」に書いた。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。