リストが300件を超えたあたりから、急にカクつき始める
Rork で日記アプリの初期版を作って、サンプルデータで動作確認している時はとても快適でした。ところがユーザーが200件、300件と投稿を貯めていくと、スクロールが目に見えて重くなり、画像付きのカードを並べたタイムラインでは下にスワイプするたびに白いプレースホルダーが一瞬見えるようになりました。
最初は画像のキャッシュが原因かと疑いました。実際には FlatList のレンダリング負荷が上限に達していたのです。同じデータで FlashList v2 に置き換えたところ、スクロールは滑らかになり、メモリ使用量も明確に下がりました。Rork で生成したアプリを FlashList v2 へ移行するときの考え方と、私が実際に詰まったポイントを書き残します。
FlatList が遅くなる仕組みは、見えていないところにあります
FlatList は React Native 標準のリストですが、内部では VirtualizedList を継承して、画面外のアイテムをアンマウントする仕組みで動きます。問題は、各アイテムをマウントするたびに React のリコンサイル処理と JS スレッドでのレイアウト計算が発生することです。
カードに画像が入っていて、Reanimated のアニメーションが乗っていて、TouchableOpacity が複数あるような構造だと、1アイテムのマウントだけで数ミリ秒を消費します。これが画面に20件並ぶだけで、スクロールが指に追従しないレベルの遅延に化けます。
FlashList は Shopify が開発したリストで、この問題を「セルのリサイクル」というアプローチで解決しています。アイテムをアンマウントするのではなく、既存のセルを再利用してデータだけ差し替える、UIKit や RecyclerView と同じ思想です。v2 ではレイアウトの計測が実行時に自動で行われるようになり、estimatedItemSize を渡さなくても動きます。
v2 で変わった一番大きいこと: estimatedItemSize は書かなくていい
FlashList v1 を触った経験がある方なら、estimatedItemSize が必須プロパティだったことを覚えているはずです。これは初回レンダリングで「だいたいこのくらいの高さ」というヒントを渡すものでしたが、値を間違えると画面に空白ができたり、スクロール位置が飛んだりする原因になっていました。
v2 ではこのプロパティが不要になり、描画されたセルを内部で測りながら高さを見積もる方式に変わりました(コンパイラが計算するわけではありません)。私の感覚では「v1 で苦労した人ほど v2 で楽になる」設計です。
移行の前に確かめておくこと
v2 は New Architecture(Fabric)を前提に作り直されたバージョンです。旧アーキテクチャのままのプロジェクトでは v2 を使えず、v1 に留まる必要があります。Expo SDK と React Native のバージョンは、手元の package.json と公式の互換表で一度突き合わせてください。この確認を飛ばして npx expo install を走らせ、ビルドエラーの前で手が止まった夜がありました。
差分を整理すると、次のようになります。
| 項目 | FlatList | FlashList v1 | FlashList v2 |
|---|---|---|---|
| 画面外のセル | アンマウントして破棄 | リサイクルして再利用 | リサイクルして再利用 |
| 高さの見積もり | getItemLayout で任意指定 | estimatedItemSize が必須 | 指定不要(実行時に計測) |
| 前提アーキテクチャ | どちらでも可 | どちらでも可 | New Architecture が前提 |
| セルの状態リセット | 不要(都度マウント) | 自前で対処 | useRecyclingState も利用可 |
表の内容は私が確認した時点のものです。細部は FlashList の公式ドキュメントで最新の記述をご覧ください。
Rork で生成したアプリへの移行手順
Rork はテンプレートで FlatList を使うことが多いので、まずはインストールから始めます。
# プロジェクトのルートで実行
npx expo install @shopify/flash-list
# Reanimated を併用している場合は最新版に揃える
npx expo install react-native-reanimated次に、FlatList を使っているコンポーネントを差し替えます。最小構成のビフォーアフターです。
// Before — FlatList 版
import { FlatList } from "react-native";
export function PostList({ posts }: { posts: Post[] }) {
return (
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
// 画面外プリロード距離・初期表示数なども調整していたが効果は限定的
windowSize={10}
initialNumToRender={8}
removeClippedSubviews
/>
);
}// After — FlashList v2 版
import { FlashList } from "@shopify/flash-list";
export function PostList({ posts }: { posts: Post[] }) {
return (
<FlashList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <PostCard post={item} />}
// estimatedItemSize は v2 では不要
// 画像付きカードでも長いリストが滑らかに動く
/>
);
}ポイントは「FlatList の最適化プロパティをほぼ全部消せる」ことです。windowSize initialNumToRender removeClippedSubviews getItemLayout といった FlatList 向けのチューニングは、FlashList に持ち込む必要がありません。私は移行時に古いプロパティを残してしまい、警告が出ても見落としていた経験があるので、置換後に必ず一度コンソールログを確認してください。
高さがバラバラなカードでも壊れない書き方
FlashList v2 で最も体感差が出るのは、アイテムごとに高さが変わるリストです。SNS のタイムラインのように、テキストだけのカードと画像付きカードが混在するリストを考えます。
type FeedItem =
| { type: "text"; id: string; body: string }
| { type: "image"; id: string; uri: string; caption: string };
export function Timeline({ items }: { items: FeedItem[] }) {
return (
<FlashList<FeedItem>
data={items}
keyExtractor={(item) => item.id}
// getItemType を渡すとセルのリサイクル効率が大きく上がります
getItemType={(item) => item.type}
renderItem={({ item }) => {
if (item.type === "image") return <ImageCard item={item} />;
return <TextCard item={item} />;
}}
/>
);
}getItemType は v1 から存在するプロパティですが、v2 でも引き続き重要です。これを書いておくと、画像カードと文字カードがそれぞれ別のリサイクルプールで管理されるため、構造の違うセル同士で中身を無理に入れ替える無駄が減ります。私は最初これを省略して、画像と文字が混ざったリストで一瞬チラつく現象に悩まされました。種類が明確に分かれるリストには、付けておくと安心です。
詰まりやすい3つのポイント
私が実際に踏んだ落とし穴を共有します。同じ罠を踏まずに済むはずです。
1つ目は ScrollView の中にネストしてしまうことです。 これは VirtualizedList 全般に言える話ですが、FlashList も例外ではありません。<ScrollView> の中に FlashList を置くと、仮想化が無効になって全件マウントされ、結局 FlatList より重くなることがあります。スクロールが必要な親レイアウトが欲しい場合は、ListHeaderComponent と ListFooterComponent で代替することを徹底してください。詳しい背景は VirtualizedList のネスト警告を解消する でも触れています。
2つ目は、行コンポーネントに key を付けてしまうことです。 FlatList の癖で renderItem の返す要素に key={item.id} を書くと、リサイクルのたびにセルが作り直されてしまい、FlashList の利点が消えます。識別は keyExtractor に任せ、行の側では key を書かないようにしています。ついでに keyExtractor もコンポーネントの外で const keyExtractor = (item: Post) => item.id と定義しておくと、毎回の関数生成を避けられます。
3つ目は data 配列の不変性です。 FlashList は data 参照の変化を見て差分を計算します。並び替えや絞り込みで新しい配列を作るときは setItems([...items, newItem]) のように毎回新しい配列を返すことが前提です。items.push(newItem) のような破壊的更新だと再描画されません。これは FlatList でも同じですが、FlashList は内部最適化が強い分、ミスが目に見えにくいので注意してください。Zustand や Redux に配列を保存している場合は、セレクタが描画ごとに同じ参照を返すかも合わせて確認してください。参照が毎回変わるとリサイクルプールが乱れて、せっかくの v2 の恩恵が消えてしまいます。
Reanimated とジェスチャー付きカードを扱うときの注意
Rork で作るアプリでは、スワイプ操作や拡大アニメーションを Reanimated で組み込んだカードが頻繁に登場します。FlatList ではジェスチャーの状態を維持するために再描画と戦う場面が多かったのですが、FlashList v2 ではほとんどそのまま動きます。ただし2点だけ気をつけたい点があります。
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { FlashList } from "@shopify/flash-list";
function SwipeableCard({ post }: { post: Post }) {
const offset = useSharedValue(0);
const cardStyle = useAnimatedStyle(() => ({
transform: [{ translateX: withSpring(offset.value) }],
}));
// セルがリサイクルされて別アイテムに変わったときは
// post.id を依存にした useEffect 等で offset.value を 0 にリセットすると安全です
// (通常の useState の代わりに useRecyclingState を使う方法もあります)
return <Animated.View style={cardStyle}>{/* カード本体 */}</Animated.View>;
}1点目は、共有値を「セル単位」ではなく「アイテム単位」で初期化することです。FlashList が別の投稿用にセルを再利用するとき、コンポーネントのインスタンスは残ったまま props だけが切り替わります。ジェスチャー状態を ref に保持してしまうと、リサイクルされたカードが前のカードのスワイプ量を引き継いだ状態で表示されます。post.id を依存にした副作用で共有値を初期化するのが最も安全です。
2点目は、重いカードを React.memo で包むことです。FlashList は行コンポーネントを自動でメモ化しないため、親の再描画が起きると見えている全カードが再描画される可能性があります。memo と安定した keyExtractor の組み合わせで、無駄な再描画を確実に減らせます。
体感ではなく数値で確認する
移行の効果は、感覚だけでなく React Native の Performance Monitor で確認できます。開発ビルドでデバイスを振って Show Perf Monitor を有効にすると、JS スレッドと UI スレッドの FPS が画面右上に表示されます。
// 簡易な計測用ラッパー(開発ビルドのみ。厳密な FPS 計測ではなく目安です)
import { InteractionManager } from "react-native";
export function trackScrollPerformance(label: string) {
const start = Date.now();
return InteractionManager.runAfterInteractions(() => {
const elapsed = Date.now() - start;
if (__DEV__) {
console.log(`[perf] ${label}: ${elapsed}ms`);
}
return elapsed;
});
}私の手元の端末では、500 件の画像カードリストで、FlatList 時に 35〜40fps だったものが、FlashList v2 への移行後は 58〜60fps 付近で安定しました。数値は端末とカードの重さで大きく変わるため、あくまで一例です。ご自身のアプリでは、移行の前後に同じ操作で測り比べてみてください。
移行は1コンポーネントずつ、スクロールが重い箇所から
最後にお伝えしたいのは、FlashList v2 への移行はアプリ全体を一度に置き換える必要はないということです。重いリストを一つ選び、置き換えて、数字で見届けます。 この順番だけは崩さないようにしています。全部を一度に直そうとして、どこが効いたのか分からなくなったのだと、あとで気づいたことがありました。最もスクロールが重いリスト1つだけを差し替えて、効果を計測します。それで満足できれば次のリスト、というように段階的に進めるのが現実的です。
今日まず試すとしたら、あなたのアプリで一番長くなりがちなリスト画面を1つ選んで、上の最小構成のサンプルだけを適用してみてください。30分以内で差し替えが終わり、次にアプリを開いた瞬間に違いが分かるはずです。アニメーションのあるカードを並べているなら、Reanimated 3 と Gesture Handler の組み合わせ方 も併せて見直すと、さらにスムーズな体験に近づきます。