リストが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 で楽になる」設計です。
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 では不要
// 画像付きカードでも 1000 件スクロールが滑らかになる
/>
);
}ポイントは「FlatList の最適化プロパティをほぼ全部消せる」ことです。windowSize initialNumToRender removeClippedSubviews getItemLayout などのチューニングは 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 でも引き続き重要です。これを書いておくと、画像カードと文字カードがそれぞれ別のリサイクルプールで管理されるため、内部の DOM 構造の差異が原因で再描画が走ることがなくなります。私は最初これを省略して、画像と文字が混ざったカードで一瞬チラつく現象に悩まされたので、種類が明確に分かれるリストには必ず付けることをおすすめします。
詰まりやすい3つのポイント
私が実際に踏んだ落とし穴を共有します。同じ罠を踏まずに済むはずです。
1つ目は ScrollView の中にネストしてしまうことです。 これは VirtualizedList 全般に言える話ですが、FlashList も例外ではありません。<ScrollView> の中に FlashList を置くと、仮想化が無効になって全件マウントされ、結局 FlatList より重くなることがあります。スクロールが必要な親レイアウトが欲しい場合は、ListHeaderComponent と ListFooterComponent で代替することを徹底してください。詳しい背景は VirtualizedList のネスト警告を解消する でも触れています。
2つ目は keyExtractor をインライン関数で都度作ってしまうことです。 一見問題なさそうに見えますが、レンダリングごとに新しい関数参照が生成されるため、v2 のリサイクル判定が混乱します。コンポーネント外で const keyExtractor = (item: Post) => item.id のように定義するか、useCallback でラップしてください。
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 にリセットすると安全です
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 が画面右上に表示されます。
// パフォーマンス計測用のラッパー(開発ビルドのみ)
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 時は JS スレッドが 35〜40fps だったのが、FlashList v2 移行後は 58〜60fps で安定しました。スクロール中に新しい画像が読み込まれる場面でも、UI スレッドのフレームドロップがほぼなくなりました。
移行は1コンポーネントずつ、スクロールが重い箇所から
最後にお伝えしたいのは、FlashList v2 への移行はアプリ全体を一度に置き換える必要はないということです。最もスクロールが重いリスト1つだけを差し替えて、効果を計測します。それで満足できれば次のリスト、というように段階的に進めるのが現実的です。
今日まず試すとしたら、あなたのアプリで一番長くなりがちなリスト画面を1つ選んで、上の最小構成のサンプルだけを適用してみてください。30分以内で差し替えが終わり、次にアプリを開いた瞬間に違いが分かるはずです。アニメーションのあるカードを並べているなら、Reanimated 3 と Gesture Handler の組み合わせ方 も併せて見直すと、さらにスムーズな体験に近づきます。