異なる型の兄弟がsame-keyを持つと、対象日ソフトナビで一覧が複製された
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
異なる型の兄弟要素が同一親内で同じkeyを持つこと自体は通常問題にならないが、条件付き短絡で隣接させて並べた配置では、対象日のソフトナビ再レンダ時に一覧が複製される実バグとして観測された。
結論
異なる型の兄弟要素が同一親内で同じkeyを持つこと自体は、Reactの通常の照合では問題にならない。 だが「同一親・隣接・条件付き短絡でのレンダリング」という配置が重なったとき、対象日を?date=のソフトナビで変える再レンダリングで「来校者一覧」が複製され「生徒呼び出し」が下へ押し出される、という実バグが本番で観測された。「同じkeyの兄弟=未定義動作」という一般化は誤りで、実際に発火する条件はもっと狭い。
症状
編集画面は対象日を?date=で持ち、ソフトナビ(ページ遷移なしの同一コンポーネントツリーでの再レンダリング)で日付を切り替える。来校者一覧と生徒呼び出しは、それぞれshowVisitors/showCalloutsの条件付き短絡で同じ親に隣接して並んでいた。
// page.tsx:168-191(修正前・85d8a613時点)
{showVisitors || showCallouts ? (
<div className={boardLayout.grid} style={{ marginTop: "1rem" }}>
{showVisitors && board?.visitors ? (
<VisitorsEditor
key={date}
classId={classId}
date={date}
initialItems={board.visitors}
/>
) : null}
{showCallouts && board?.callouts ? (
<CalloutsEditor
key={date}
classId={classId}
date={date}
initialItems={board.callouts}
/>
) : null}
</div>
) : null}
VisitorsEditorとCalloutsEditorは異なるコンポーネント(型)だが、どちらもkey={date}という同じ文字列をキーに持つ。対象日を2026-06-21から2026-06-22にソフトナビで切り替えると、「来校者一覧」が2つに複製され、「生徒呼び出し」が画面の下へ押し出される、という不具合が本番で発生した。
原因
Reactの差分照合は、兄弟要素を型と位置で照合する。VisitorsEditorとCalloutsEditorは型が異なるため、通常であれば同じkeyを持っていても互いを取り違えることはない。つまり「異なる型の兄弟が同じkeyを持つこと」単体は、このバグの直接の原因とは言い切れない。
実際に複製が起きたのは、次の3条件が同時に揃ったときだった。
- 同一親に2つの要素が属している
showVisitors && .../showCallouts && ...という条件付き短絡で、表示・非表示が切り替わりうる- 2要素が隣接して並んでいる
対象日の変更(ソフトナビ)による再レンダリングの最中、この配置でkey={date}という同一キーの兄弟が条件付きで出入りすると、Reactの内部的な子要素リストの照合で想定外の挙動が起きる。レビューではこの挙動自体を「型が違う兄弟のkey衝突は一般に未定義」と断定的に記録することを避けた。観測された事実は「この特定の配置・このレンダリングパスでは複製が起きる」ことだけであり、より広い一般化は実証されていない。
直し方
2つの兄弟要素を独立した1つのコンポーネントVisitorsCalloutsSectionにまとめ、それぞれに衝突しない安定したkeyを与えた。
// VisitorsCalloutsSection.tsx:1-69(修正後・7111c0286617時点)
"use client";
import type { ClassVisitor, StudentCallout } from "@/types/db";
import { CalloutsEditor } from "./CalloutsEditor";
import { VisitorsEditor } from "./VisitorsEditor";
import boardLayout from "./board-layout.module.css";
export function VisitorsCalloutsSection({
classId, date, showVisitors, showCallouts, visitors, callouts,
}: {
classId: string; date: string; showVisitors: boolean; showCallouts: boolean;
visitors: ClassVisitor[] | null; callouts: StudentCallout[] | null;
}) {
if (!(showVisitors || showCallouts)) { return null; }
return (
<div className={boardLayout.grid} style={{ marginTop: "1rem" }}>
{showVisitors && visitors ? (
<VisitorsEditor key={`visitors-${date}`} classId={classId} date={date} initialItems={visitors} />
) : null}
{showCallouts && callouts ? (
<CalloutsEditor key={`callouts-${date}`} classId={classId} date={date} initialItems={callouts} />
) : null}
</div>
);
}
key={date}という元の指定の意図(対象日が変わったら新しいデータで再初期化する)自体は正しかった。それをvisitors-${date}/callouts-${date}という、要素ごとに重複しない文字列に変えることで維持しつつ、2要素間でのkey衝突を構造的に無くした。親側(page.tsx)はこの1コンポーネントを呼ぶだけになり、「同一親・隣接・条件付き短絡」という発火条件の配置自体を解消した。
回帰確認は、日付を交互に切り替えながら「来校者一覧」の見出しが常に1個であることを検証するテストで取っている。
// visitors-callouts-section.test.tsx(抜粋、該当箇所の意図)
// 6/22: データ有 → 衝突キーだとここで「来校者一覧」が 2 つに複製していた。
// render → rerender で対象日を 2026-06-21 → 06-22 → 06-23(データ有無を交える)と変えても、
// screen.getAllByRole("heading", { name: "来校者一覧", level: 2 }) は常に length 1 を維持する。
再発防止
このバグの調査過程では、最初に「異なる型の兄弟要素が同じkeyを持つと未定義動作になる」という一般化で説明しようとする案が出たが、レビューで「Reactは型と位置で照合するため、型が違う限り通常は問題にならない」という反証が入り、この一般化は採用されなかった。代わりに、実際に発火した配置(同一親・隣接・条件付き短絡・同一key)だけを観測事実として記録し、コンポーネント分割によってその配置自体をコード上から無くす方向で修正している。「keyが被れば危険」という広すぎる教訓ではなく、「同じ親の中で条件付きに出入りする兄弟に同一keyを与えない」という、再現条件に即した狭い教訓として残っている。
よくある質問
Q1異なる型の兄弟要素が同じkeyを持つと、必ず複製されるのですか?
いいえ。Reactは兄弟要素を型と位置で照合するため、型が違う要素同士が同じkeyを持つこと自体は通常は問題になりません。本件で複製が起きたのは、同一親・隣接・条件付き短絡レンダリングという特定の配置が重なった場合に限られ、『keyが被ると未定義動作になる』という一般化は誤りです。
Q2ソフトナビとは何ですか?なぜ関係するのですか?
ページ遷移を伴わず、同じコンポーネントツリーのままクエリパラメータ(?date=)だけを変えて再レンダリングする画面遷移です。マウント・アンマウントを伴うフルページ遷移とは違い、既存のコンポーネントインスタンスがそのまま再利用されるため、key指定が内部状態の初期化タイミングに直接影響します。
Q3修正はkeyの値を変えるだけですか?
keyの値を衝突しない形(visitors-${date} / callouts-${date})に変えるだけでなく、2つの兄弟要素を1つの独立コンポーネントにまとめる構造変更も同時に行いました。日付変更時に新しいデータで再初期化するという元のkey={date}の意図自体は、新しいkeyにもdateを含めることで維持しています。
確認した環境
- Next.js ^16.0.0 / React ^19.0.0
- 2026-06-21に本番で観測、2026-06-22に修正(PR #1123)
この記事の根拠
- TypeScriptファイル 168〜191行目コミット 85d8a61
- TypeScriptファイル 1〜69行目コミット 7111c02
- TypeScriptファイル 1〜90行目コミット 7111c02
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。