Google Play Consoleを開いたら、28日間で50ユーザー以上が同じスタックトレースでクラッシュしているのを見つけたことがあります。私が2014年から個人開発しているBeautiful HD Wallpapers(iOS/Android、累計5,000万DL超)のAndroid版v2.0.0リリース後のことでした。
スタックトレースをたどると、原因はあっけなく見つかりました。リスト表示に渡す配列を、直接書き換えていたのです。これはAndroidのRecyclerViewだけでなく、Rorkが生成するReact NativeのFlatListでもまったく同じ問題が起きます。私が実際に踏み、v2.1.0で解消したパターンを、同じ状況に直面した方に向けて具体的に紹介します。
なぜFlatListはこのパターンで落ちるのか
JavaScriptでは、配列や変数はオブジェクトへの「参照」を保持しています。const newList = items と書いても、新しい配列は作られず、同じメモリ上のオブジェクトを指す参照が増えるだけです。
// このコードは見た目は動いているが、危険なパターン
const handleCategoryChange = (newCategory) => {
const filtered = allWallpapers; // 参照コピー(新しい配列ではない)
filtered.sort((a, b) => a.category.localeCompare(b.category));
setWallpapers(filtered); // Reactは「同じ配列だ」と認識してスキップすることがある
};FlatListは内部でアイテム数とインデックスを追跡しています。Stateの参照が変わらないまま配列の中身だけが変わると、FlatListが保持する内部状態と実際のデータがずれます。高速スクロール時や素早い操作で、そのずれが Inconsistency detected や Cannot read properties of undefined というクラッシュとして表れます。
開発環境では再現しにくく、本番の実機・実ユーザーで突発的に起きるため、対処が後手に回りやすいのもこのパターンの特徴です。端末の性能やネットワーク状況、ユーザーの操作速度によってタイミングが変わるため、「自分では全く再現しないのにPlay Consoleにはクラッシュが積み上がっている」という状況になりやすいです。
Rorkが生成するコードは構造がしっかりしていますが、そのコードを編集・拡張していく過程でこの問題が混入することがあります。特にAPIのレスポンスをそのままStateに入れていたり、既存の配列をループで加工してからセットしているコードに注意が必要です。 Reactの公式ドキュメントでは「Stateをイミュータブルに扱う」ことが基本原則として挙げられています。これは「Stateに格納したオブジェクトや配列を直接変更してはいけない、変更したい場合は新しいオブジェクト・配列を作ってから渡す」という意味です。FlatListのクラッシュはこの原則を破ったときに発生する代表的な症状のひとつです。個人開発を10年以上続けるなかで、この原則を体感として理解できたのは、実際にクラッシュレポートが50件を超えてからでした。特にAPIのレスポンスをそのままStateに入れていたり、既存の配列をループで加工してからセットしているコードに注意が必要です。
防御的コピーで根治する
修正の考え方はシンプルです。StateのSetterを呼ぶ前に、必ず新しい配列を作ることです。スプレッド演算子 [...array] を使うか、filter・map など新しい配列を返すメソッドを使うか、SetterにPrev関数を渡すかの3つが主な手段です。
// ✅ スプレッド演算子で新しい配列を作る
const handleCategoryChange = (newCategory) => {
const filtered = [...allWallpapers].sort(
(a, b) => a.category.localeCompare(b.category)
);
setWallpapers(filtered); // 新しい配列参照なのでReactが確実に検知する
};
// ✅ Setterに関数を渡すと常に最新のStateを受け取れる
const addItem = async () => {
const newItem = await fetchItem();
setItems(prev => [...prev, newItem]); // prevは呼ばれた時点の最新値
};
// ✅ filterやmapは新しい配列を返すので安全
const removeItem = (targetId) => {
setItems(prev => prev.filter(item => item.id !== targetId));
};[...array].sort() と array.sort() は挙動が異なります。.sort() は元の配列を書き換えます(破壊的メソッド)。コピーを先に作ってから処理する点が肝心です。同様に、splice や push も元の配列を変更する破壊的メソッドです。これらを使う場面では、先にコピーを作ることを習慣にしてください。
Rork生成コードで見落としやすい3つのパターン
Rorkが出力するコードを編集するときに、意図せず混入しやすいパターンを3つ紹介します。実際に私がBeautiful HD Wallpapersのコードで踏んだものと、React Native版の等価パターンです。
パターン1: splice/pushによる破壊的操作
削除機能を追加するときに、直感的に splice を使うと問題が起きます。
// ❌ spliceは元の配列を変更する
const deleteItem = (index) => {
const newItems = items; // 参照コピー
newItems.splice(index, 1); // 元の配列を破壊的に変更
setItems(newItems); // 同じ参照のためUIが更新されないこともある
};
// ✅ filterで新しい配列を生成する
const deleteItem = (index) => {
setItems(prev => prev.filter((_, i) => i !== index));
};filter を使うと、元の配列はそのままに、条件を満たす要素だけを持つ新しい配列が返ってきます。削除だけでなく、並び替えや絞り込みにも同じ発想が使えます。
パターン2: Object.assignによる浅いコピー漏れ
アイテムの一部フィールドを更新するときは、ネストしたオブジェクトにも注意が必要です。配列の各要素を更新する際に Object.assign を使うと、元のオブジェクトが変更されてしまいます。
// ❌ Object.assignは参照のシャローコピー。元のオブジェクトが変更される
const updateItem = (id, changes) => {
setItems(prev => prev.map(item =>
item.id === id ? Object.assign(item, changes) : item
));
};
// ✅ スプレッドで新しいオブジェクトを作る
const updateItem = (id, changes) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, ...changes } : item
));
};スプレッド { ...item, ...changes } は、item のすべてのプロパティをコピーした新しいオブジェクトを作り、そこに changes を上書きします。元の item オブジェクトには一切触れないので安全です。
パターン3: 非同期処理での古いStateへの参照
APIから取得したデータをStateにセットするとき、クロージャで古い items を参照してしまうケースです。これはクラッシュではなく「データが混ざる」バグとして現れることもあります。
// ❌ itemsはAPIコール開始時点の値。途中で別の更新があると古い値と合算される
const loadMore = async () => {
const result = await fetchNextPage();
setItems([...items, ...result.data]); // itemsが古い値かもしれない
};
// ✅ 関数形式にすれば常に最新値が渡ってくる
const loadMore = async () => {
const result = await fetchNextPage();
setItems(prev => [...prev, ...result.data]);
};setItems(prev => ...) の形式にするだけで、Reactが呼び出し時点の最新のStateを prev として渡してくれます。非同期処理が絡む場面では、この書き方を基本にすると安全です。
修正後の結果と検出コマンド
Beautiful HD Wallpapersでは、v2.1.0でこの修正を全面的に適用しました。28日間で50ユーザー以上に発生していたクラッシュは、リリース後7日間でほぼゼロになっています。Android Vitalsの「ユーザー体験への影響が大きいクラッシュ」グラフが、修正後の計測期間ではフラットになっていることを確認しました。
Rorkプロジェクトで同様の問題がないかを確認するには、以下のコマンドが使えます。
# 直接代入して配列を使い回している箇所を検索
grep -rn "const new.*= items\|const new.*= data\|const new.*= list" src/ --include="*.ts" --include="*.tsx"
# splice/push/sort など破壊的操作の使用箇所
grep -rn "\.splice(\|\.push(\|\.sort(" src/ --include="*.tsx" | grep -v "// ✅"これで出てきたファイルを順に確認し、Setter呼び出しの直前で元の配列を書き換えていないかを確認してみてください。危険な箇所が見つかったら、スプレッドか関数形式のSetterに置き換えるだけです。
この調査を一度実施しておくと、クラッシュが起きる前に潜在的な問題を発見できます。大規模な機能追加の前に走らせておくと、リリース後のAndroid Vitalsのグラフをきれいに保つ助けになります。特にカテゴリ切り替えやページング(追加読み込み)の実装後は、このチェックをルーティンとして取り入れることをお勧めします。
既存記事から内部リンク確認時の注意
FlatListのパフォーマンス問題が気になる方は、FlatListをFlashListに移行する手順を解説したRorkのFlashList v2移行でスクロール性能を改善するも参考になります。クラッシュが解消した後のパフォーマンス最適化の次のステップとして取り組むとよいでしょう。
Stateが更新されても画面に反映されない問題については、Rorkで状態が更新されない・再レンダリングされない問題の解決もあわせてご覧ください。今回解説した参照渡しの問題と密接に関連しています。
次にやること
今のRorkプロジェクトのコードで setXxx([... や setXxx(prev => を使っていない箇所を一度検索してみてください。見つかった箇所が、次のクラッシュレポートの発生源になっている可能性があります。
個人開発を10年以上続けてきた中で感じるのは、このパターンへの対処は「正しいやり方を覚えたら終わり」の類の問題だということです。一度意識するだけで、以降のコードレビューで自然とチェックできるようになります。同じクラッシュで悩んでいる方の参考になれば幸いです。