Rork で「ヘッダーがあって、その下に長い記事リストがある」ような画面を作っていると、ある日突然コンソールに次のような赤い警告が表示されます。
VirtualizedLists should never be nested inside plain ScrollViews
with the same orientation because it can break windowing and other
functionality - use another VirtualizedList-backed container instead.
私自身、Rork で複数のアプリを開発していて、この警告に何度も遭遇しました。動作自体はしてしまうので「とりあえず無視」と片付けがちですが、放置するとリストが長くなった瞬間にスクロールがガタつき、メモリ消費が一気に膨らみます。今回は Rork から生成された React Native プロジェクトで、この警告を「正しく」解消するためのパターンを整理しておきます。
なぜこの警告が出るのか
FlatList や SectionList の正体は VirtualizedList です。これは「画面に映っている分だけレンダリングし、見えなくなった行は破棄する」仕組みで、長いリストでも軽快に動くように設計されています。
ところが、これを ScrollView の中に同じ縦方向で入れてしまうと、外側の ScrollView が中の全要素を一度に展開して測ろうとします。すると FlatList の仮想化が完全に無効化され、1万件のデータを全部メモリに展開するといった事態が起きます。警告はそれを防ぐためのものです。
私はこれを「2つの巻き取り装置を直列につなぐと、結局どちらも巻き取れなくなる」というイメージで理解しています。スクロールを担当するコンテナは画面に1つだけ、というのが基本です。
解決策1: FlatList の Header / Footer に寄せる(最有力)
一番おすすめなのが、ScrollView をやめて FlatList 1枚で構成し直す方法です。固定したいヘッダーは ListHeaderComponent に、フッターは ListFooterComponent に渡します。
import { FlatList, View, Text, Image, StyleSheet } from "react-native";
type Article = { id: string; title: string; excerpt: string };
export default function FeedScreen({ articles }: { articles: Article[] }) {
// ヘッダーに描きたかった内容を関数として渡す
// → これで ScrollView をネストせずに済む
const Header = (
<View style={styles.header}>
<Image source={require("./assets/banner.png")} style={styles.banner} />
<Text style={styles.heading}>新着の記事</Text>
</View>
);
return (
<FlatList
data={articles}
keyExtractor={(item) => item.id}
ListHeaderComponent={Header}
ListFooterComponent={<View style={{ height: 32 }} />}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.excerpt} numberOfLines={2}>
{item.excerpt}
</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
header: { padding: 16, gap: 12 },
banner: { width: "100%", height: 140, borderRadius: 12 },
heading: { fontSize: 20, fontWeight: "700" },
row: { padding: 16, borderBottomWidth: 1, borderBottomColor: "#eee" },
title: { fontSize: 16, fontWeight: "600", marginBottom: 4 },
excerpt: { color: "#555" },
});警告が消えるだけでなく、ヘッダーやフッターも仮想化の恩恵を受けられるので、リストが長くなってもスクロールが滑らかなままです。Rork の AI に修正を依頼するときも、「ScrollView を外して FlatList の ListHeaderComponent にまとめてください」と具体的に指示するときれいに直してくれます。
解決策2: 横方向のリストならネスト OK
警告文をよく読むと「同じ向きの場合(with the same orientation)」と書かれています。つまり、外側が縦の ScrollView、内側が横スクロールの FlatList(horizontal を true にしたもの)であればネストして問題ありません。
<ScrollView>
<Text style={styles.section}>おすすめ特集</Text>
<FlatList
data={featured}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <FeatureCard item={item} />}
/>
<Text style={styles.section}>新着</Text>
{/* ↓ こちらも縦の FlatList にしたいなら ListHeaderComponent に寄せる */}
</ScrollView>ホーム画面でよくある「カテゴリ別の横カルーセルが縦に並ぶ」レイアウトは、この組み合わせで素直に書けます。
解決策3: SectionList でセクション分割する
「ヘッダー → セクションA → セクションB」のように、構造的にグループ分けされたリストなら SectionList が自然です。複数のセクションをまとめつつ、内部は仮想化されたままになります。
import { SectionList, Text, View } from "react-native";
const sections = [
{ title: "今週のピックアップ", data: weeklyArticles },
{ title: "新着", data: latestArticles },
];
<SectionList
sections={sections}
keyExtractor={(item, index) => item.id + index}
renderSectionHeader={({ section: { title } }) => (
<Text style={styles.sectionTitle}>{title}</Text>
)}
renderItem={({ item }) => <ArticleRow item={item} />}
ListHeaderComponent={<HomeBanner />}
stickySectionHeadersEnabled
/>stickySectionHeadersEnabled を有効にすると、見出しがスクロール中に上に貼り付くので、コンテンツ量が多いアプリでも現在地が分かりやすくなります。
解決策4: 大量データなら FlashList に置き換える
リストの件数が数百〜数千を超えてくる場合、Shopify が公開している @shopify/flash-list への置き換えを検討してください。FlatList と API がほぼ同じなのに、内部実装はセル再利用が効いていて、スクロール時のフレーム落ちが大幅に減ります。
import { FlashList } from "@shopify/flash-list";
<FlashList
data={articles}
estimatedItemSize={88} // 行の平均高さを必ず指定する
keyExtractor={(item) => item.id}
ListHeaderComponent={<HomeHeader />}
renderItem={({ item }) => <ArticleRow item={item} />}
/>estimatedItemSize の指定を忘れると本来の性能が出ないので注意してください。Rork から生成した直後だと FlatList で実装されることが多いため、データ量が増えてきたタイミングで FlashList へ切り替える、という運用が私の中では定着しています。
やってはいけない回避策
警告を消したい一心で、つい ScrollView の nestedScrollEnabled を有効にしたり、FlatList の scrollEnabled を false にして「全件レンダリングさせる」方向で逃げてしまうケースがあります。これは警告が消えるだけで根本解決にはなりません。
特に scrollEnabled={false} + 親 ScrollView の組み合わせは、データ件数が増えた瞬間にメモリ不足でアプリがクラッシュする原因になります。私も初期に一度やらかして、TestFlight で配布した後に低スペック端末で連続クラッシュ、という事故を起こしました。回避策ではなく、上の4つのうちどれかに必ず寄せるのが安全です。
もうひとつ、よく見るアンチパターンが「リストを <View style={{ flex: 1, height: 800 }}> で囲んで高さを固定する」やり方です。これは警告が消えたように見えるだけで、実際は仮想化が壊れたまま、強制的に高さの上限を与えているだけです。明示的なピクセル高さでリストを囲みたくなったら、それは解決策1へ立ち戻るサインだと考えてください。
もうひとつだけ補足しておくと、React Navigation を使っている場合、画面のコンテナ自体がすでに flex: 1 の View です。スクロールさせたいからといって、その上に ScrollView を被せる必要はありません。FlatList を画面直下に置くだけで、ちゃんとスクロールします。Rork の AI が ScrollView を勝手に足してくる場面で、そのまま受け入れずに「外しても動くか」を一度疑ってみるのがおすすめです。
見落としがちな2つのケース
このルールが理解できていても、つまずきやすいパターンが2つあります。
ひとつは KeyboardAvoidingView の中に ScrollView、その中に FlatList という組み合わせです。検索フォームの下に候補リストを出す画面でよく出来上がる構造です。ここでも基本は同じで、外側の ScrollView を外して、フォーム部分を FlatList の ListHeaderComponent に寄せます。KeyboardAvoidingView は FlatList と直接組み合わせても問題なく動きます。
もうひとつは Modal の中で ScrollView が FlatList を包んでいる ケース。モーダルは別世界のように感じますが、内部に対しても同じ警告が適用されます。モーダルの中身もひとつの画面と捉えて、解決策1を適用してください。
外側の ScrollView を外したのに警告が消えない場合、自作の <Screen> や <Container> といったラッパーコンポーネントの中に ScrollView が潜んでいることがあります。私はこういうとき、疑わしいコンポーネントを <View style={{ flex: 1, borderWidth: 4, borderColor: "red" }}> で包んで、画面上で「どれがリストを包んでいるか」を視覚的に確認しています。
どのパターンを選ぶか — 私の判断軸
実際の開発で迷ったら、こんな順番で考えています。
- リストが「画面のメイン要素」なら → 解決策1(FlatList + ListHeaderComponent)
- 「縦の中に横スクロールが混ざる」なら → 解決策2(horizontal FlatList をネスト)
- セクションごとに区切りたいなら → 解決策3(SectionList)
- 件数が大きい/パフォーマンスが悩みなら → 解決策4(FlashList)
迷ったらまず解決策1を試してみてください。多くのケースで、ScrollView を外すだけでコードがすっきりして、警告も自然に消えます。
React Native 全体のコンポーネント設計や仮想化の考え方をもう少し体系的に学びたい方には、Rork で生成されたコードを読み解く React Native 基礎ガイド が参考になるかもしれません。フォームや入力で似たような「キーボードに隠れる」問題に困っている場合は、Rork でキーボードと入力欄が重なる問題の修正ガイド も合わせて読むと、スクロール周りの設計判断が一段深く理解できます。
まずは1画面だけ直してみてください
VirtualizedList の警告は、見つけた瞬間に全画面を一気に直そうとすると挫折しがちです。私自身は、警告がいちばん多く出ている画面を1つだけ選び、そこを FlatList + ListHeaderComponent に寄せ替えるところから始めるようにしています。
低スペック端末でも滑らかなスクロールを体感できると、「あぁ、これが警告が言いたかったことか」と一気に腹落ちします。Rork でこの先もっと長いリストを扱うアプリを作るときに、確実に効いてくる土台になります。