もう迷わない!React Native FlatList#onEndReachedの完全攻略ガイド

2025-05-31

具体的には、以下のような状況で役立ちます。

  • 「もっと見る」ボタンの代替
    ユーザーが手動で「もっと見る」ボタンをクリックする代わりに、リストをスクロールするだけで次のコンテンツが自動的に読み込まれるようにしたい場合。
  • 無限スクロール(Infinite Scrolling)
    リストの最後までスクロールされたときに、さらに多くのデータをサーバーからロードして表示する、といった機能を実現する際に非常に重要です。

仕組み

onEndReached プロパティには、リストの末尾にスクロールしたときに呼び出される関数を渡します。この関数が呼び出されるタイミングは、onEndReachedThreshold プロパティによって調整できます。

  • onEndReachedThreshold: これは FlatList の終わりからどのくらいの距離(ビューポートの割合)で onEndReached が呼び出されるかを指定する数値です。たとえば、0.5 と設定すると、リストの終わりからリストの高さの半分に達したときに onEndReached がトリガーされます。デフォルト値は 0 で、これはリストの終わりまで完全にスクロールされたときにのみトリガーされることを意味します。

使用例

以下に簡単な使用例を示します。

import React, { useState, useEffect } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';

const DATA = Array.from({ length: 20 }, (_, i) => ({ id: String(i), title: `Item ${i + 1}` }));

const MyFlatList = () => {
  const [data, setData] = useState(DATA);
  const [loading, setLoading] = useState(false);
  const [page, setPage] = useState(1);

  const loadMoreData = () => {
    if (loading) return; // 既に読み込み中の場合は何もしない

    setLoading(true);
    // 実際にはAPIからデータをフェッチする
    setTimeout(() => {
      const newData = Array.from({ length: 10 }, (_, i) => ({
        id: String(data.length + i),
        title: `More Item ${data.length + i + 1}`,
      }));
      setData(prevData => [...prevData, ...newData]);
      setLoading(false);
      setPage(prevPage => prevPage + 1);
      console.log('データを追加しました。現在のアイテム数:', data.length + newData.length);
    }, 1500); // 1.5秒の遅延をシミュレート
  };

  const renderFooter = () => {
    if (!loading) return null;
    return (
      <View style={styles.footer}>
        <ActivityIndicator size="large" />
        <Text>読み込み中...</Text>
      </View>
    );
  };

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View style={styles.item}>
          <Text style={styles.title}>{item.title}</Text>
        </View>
      )}
      onEndReached={loadMoreData} // リストの末尾に到達したときにloadMoreDataを呼び出す
      onEndReachedThreshold={0.5} // リストの終わりから0.5の距離でonEndReachedをトリガー
      ListFooterComponent={renderFooter} // リストのフッターにローディングインジケータを表示
    />
  );
};

const styles = StyleSheet.create({
  item: {
    backgroundColor: '#f9f9f9',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 5,
  },
  title: {
    fontSize: 18,
  },
  footer: {
    paddingVertical: 20,
    borderTopWidth: 1,
    borderColor: '#ced0ce',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

export default MyFlatList;

この例では、FlatList がスクロールされて末尾に近づくと (onEndReachedThreshold={0.5})、loadMoreData 関数が呼び出されます。この関数は、新しいデータをシミュレートして FlatList のデータに追加し、ローディング状態を管理しています。

  • onEndReachedThreshold の調整
    アプリケーションのユーザーエクスペリエンスに合わせて、適切な onEndReachedThreshold の値を設定することが重要です。早すぎるとユーザーがまだ読み込みが必要だと感じていないのにデータが読み込まれ始め、遅すぎるとユーザーがデータの読み込みを待つことになります。
  • データソースの管理
    データを追加する際は、新しいデータと既存のデータを正しく結合するように注意してください。
  • 重複呼び出しの防止
    onEndReached は、ユーザーのスクロール速度によっては複数回トリガーされる可能性があります。loading フラグなどを使用して、一度のスクロールで何度もデータがフェッチされないように制御することが重要です。上記の例では if (loading) return; で対応しています。


onEndReached が初回レンダリング時に呼び出される

問題
リストが画面に表示された直後、ユーザーがスクロールしていないにもかかわらず onEndReached がトリガーされてしまうことがあります。特に、リストのコンテンツが画面の高さよりも短い場合に発生しやすいです。

原因
FlatList は、リストがレンダリングされた時点で、コンテンツの終わりが onEndReachedThreshold で設定された距離内にあると判断すると、onEndReached をトリガーします。リストのアイテム数が少ない場合や、リスト自体に十分な高さがない場合に、この状態が発生します。

解決策

  • ListFooterComponent でのローディング表示
    リストのフッターにローディングインジケータを表示する際、ローディング中でない場合は null を返すようにすることで、リストの高さが変動し、onEndReached が不必要にトリガーされるのを防ぐことができます。

  • 初期レンダリング時の条件分岐
    初回ロード時とそれ以降のロード時で処理を分けることも検討できます。例えば、page 番号を追跡し、page1 の場合は onEndReached をトリガーしない、といった方法です。

  • onEndReachedCalledDuringMomentum フラグの使用
    最も一般的な解決策です。スクロールが開始されたときにフラグを立て、onEndReached が呼び出されたときにこのフラグを確認し、既にデータがロードされている場合は処理をスキップするようにします。

    const [loading, setLoading] = useState(false);
    const onEndReachedCalledDuringMomentum = useRef(true); // useRef を使用
    
    const handleLoadMore = () => {
      if (!onEndReachedCalledDuringMomentum.current && !loading) {
        setLoading(true);
        // データのフェッチ処理
        setTimeout(() => {
          // データ追加
          setLoading(false);
          onEndReachedCalledDuringMomentum.current = true; // 読み込み完了後にリセット
        }, 1500);
      }
    };
    
    return (
      <FlatList
        data={data}
        // ... 他のプロパティ
        onEndReached={handleLoadMore}
        onEndReachedThreshold={0.5}
        onMomentumScrollBegin={() => { // スクロールが開始されたときにフラグをリセット
          onEndReachedCalledDuringMomentum.current = false;
        }}
      />
    );
    

onEndReached が複数回呼び出される(無限ループ)

問題
ユーザーが素早くスクロールしたり、ネットワークの状態によっては、onEndReached が意図せず何度も呼び出されてしまい、データが重複してフェッチされたり、無限ループに陥ることがあります。

原因

  • FlatList の高さの変化
    ListFooterComponent などでローディング表示の有無によってリストの高さが変動すると、FlatList のレイアウトが再計算され、それが onEndReached の再トリガーにつながることがあります。
  • データのフェッチ中にローディング状態が正しく管理されていない
    データフェッチの処理中に loading フラグなどが true になっておらず、続けて onEndReached がトリガーされてしまう。
  • onEndReachedThreshold の設定が低すぎる
    値が 0 や非常に小さい場合、わずかなスクロールでもトリガーされることがあります。

解決策

  • ListFooterComponent の表示制御
    ローディングインジケータをフッターに表示する場合、ローディング中でないときは完全に非表示にする(null を返す)のではなく、opacity: 0 などで高さを維持したまま見えなくすることで、レイアウトの変動を防ぎ、不必要なトリガーを減らすことができます。

  • onEndReachedThreshold の調整
    適切な値を設定します。0.5 など、リストの半分くらいまでスクロールされたらトリガーされるように設定すると、ユーザー体験とパフォーマンスのバランスが取れることが多いです。

  • loading フラグによる制御
    最も重要です。データフェッチを開始する際に loading フラグを true に設定し、フェッチが完了したら false に戻します。onEndReached 関数内でこの loading フラグが true の場合は、データのフェッチ処理をスキップします。

    const [loading, setLoading] = useState(false);
    
    const handleLoadMore = () => {
      if (loading) return; // 既に読み込み中の場合は何もしない
    
      setLoading(true);
      // データのフェッチ処理
      // ...
      setLoading(false);
    };
    

onEndReached が全く呼び出されない

問題
リストの最後までスクロールしても、onEndReached が一度もトリガーされない。

原因

  • data プロパティの更新が正しく行われていない
    新しいデータが FlatListdata プロパティに正しく追加されていない場合、リストの長さが変わらないため、末尾に到達したと認識されません。
  • FlatList に高さが設定されていない
    FlatList が親コンポーネントによって高さを制限されていない場合(例: flex: 1 が設定されていないなど)、リストの実際の高さを認識できず、スクロールイベントが正しく発生しないことがあります。
  • FlatList が ScrollView の内部にある
    FlatList は自身のスクロール機能を持っているため、親の ScrollView の中にネストされていると、FlatList のスクロールイベントが正しく伝わらず、onEndReached が機能しないことがあります。

解決策

  • data プロパティのイミュータブルな更新
    data 配列を更新する際は、新しい配列として設定する必要があります(例: [...prevData, ...newData])。既存の配列を直接変更する(push など)と、React が変更を検知できず、UIが更新されないことがあります。

  • FlatList に適切な高さを設定する
    親の Viewflex: 1 を設定するか、FlatList 自体に固定の height を設定して、FlatList がスクロール可能な領域を認識できるようにします。

    <View style={{ flex: 1 }}> {/* FlatList を View で囲み、flex: 1 を与える */}
      <FlatList
        // ...
      />
    </View>
    
  • FlatList を ScrollView の中にネストしない
    これが最も重要な点です。もしそうしている場合は、ScrollView を削除し、FlatList を独立して使用するように変更します。

Android と iOS で挙動が異なる

問題
onEndReached の挙動が Android と iOS で微妙に異なることがある。例えば、Android ではスムーズに動作するが、iOS では初回ロード時にトリガーされるなど。

原因
プラットフォーム間のスクロールイベントの処理やレンダリングの細かな違いが影響している可能性があります。

解決策

  • FlashList の検討
    FlashList (Shopify 製) は、より高性能なリストコンポーネントであり、FlatList の問題を解決するのに役立つ場合があります。ただし、API は似ていますが、内部実装が異なるため、移行には注意が必要です。
  • onEndReachedThreshold の微調整
    各プラットフォームでテストを行い、最適な onEndReachedThreshold の値を設定します。
  • onEndReachedCalledDuringMomentum フラグの使用
    上記の解決策は、プラットフォーム間の挙動の差異を吸収するのに有効です。
  • React Native のバージョン
    使用している React Native のバージョンが古い場合、バグが修正されている可能性もあるため、最新バージョンへのアップデートを検討します。
  • 最小限の再現コード
    問題が発生した場合、不要なコードを削ぎ落とし、問題が再現する最小限のコードを作成します。これにより、問題の特定と解決が容易になります。
  • デバッグログの活用
    onEndReached 関数内で console.log() を使用し、いつ、どのような条件で関数が呼び出されているかを確認します。distanceFromEnd の値などもログ出力すると役立ちます。


基本的な無限スクロールの実装

これは、最も基本的な onEndReached の使用例です。リストの最後までスクロールすると、新しいデータが追加されます。

import React, { useState, useEffect } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';

const INITIAL_DATA_COUNT = 20;
const LOAD_MORE_COUNT = 10;

// 初期データを作成する関数
const generateInitialData = () => {
  return Array.from({ length: INITIAL_DATA_COUNT }, (_, i) => ({
    id: String(i),
    title: `初期アイテム ${i + 1}`,
  }));
};

const BasicInfiniteScroll = () => {
  const [data, setData] = useState(generateInitialData());
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true); // さらに読み込むデータがあるか

  // データをロードする関数
  const loadMoreData = () => {
    if (loading || !hasMore) {
      // 既に読み込み中か、もうデータがない場合は何もしない
      return;
    }

    setLoading(true);

    // 実際のアプリケーションではAPIを呼び出してデータをフェッチします
    // ここではsetTimeoutで非同期処理をシミュレート
    setTimeout(() => {
      const newStartingIndex = data.length;
      const newItems = Array.from({ length: LOAD_MORE_COUNT }, (_, i) => ({
        id: String(newStartingIndex + i),
        title: `追加アイテム ${newStartingIndex + i + 1}`,
      }));

      setData(prevData => [...prevData, ...newItems]); // 既存データに新しいデータを追加
      setLoading(false);

      // 例えば、全データが100件の場合、これ以上読み込む必要がないと判断
      if (data.length + newItems.length >= 50) { // 例: 最大50件のデータ
        setHasMore(false);
      }

      console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
    }, 1500); // 1.5秒の遅延をシミュレート
  };

  // 各リストアイテムのレンダリング
  const renderItem = ({ item }) => (
    <View style={styles.item}>
      <Text style={styles.title}>{item.title}</Text>
    </View>
  );

  // リストのフッター部分のレンダリング(ローディングインジケータなど)
  const renderFooter = () => {
    if (!loading && hasMore) return null; // 読み込み中でなく、データがある場合は何も表示しない
    if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>; // データがない場合
    return (
      <View style={styles.footer}>
        <ActivityIndicator size="large" />
        <Text>読み込み中...</Text>
      </View>
    );
  };

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      onEndReached={loadMoreData} // リストの末尾に到達したときにloadMoreDataを呼び出す
      onEndReachedThreshold={0.5} // リストの終わりから0.5の距離でonEndReachedをトリガー
      ListFooterComponent={renderFooter} // リストのフッターコンポーネント
    />
  );
};

const styles = StyleSheet.create({
  item: {
    backgroundColor: '#f9f9f9',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 5,
    borderWidth: 1,
    borderColor: '#ddd',
  },
  title: {
    fontSize: 18,
  },
  footer: {
    paddingVertical: 20,
    borderTopWidth: 1,
    borderColor: '#ced0ce',
    alignItems: 'center',
    justifyContent: 'center',
  },
  noMoreDataText: {
    textAlign: 'center',
    paddingVertical: 20,
    color: '#888',
  },
});

export default BasicInfiniteScroll;

解説

  • ListFooterComponent={renderFooter}
    リストの最下部にローディングインジケータや「全てのデータを表示しました」というメッセージを表示します。
  • onEndReachedThreshold={0.5}
    リストの末尾から半分の位置に到達したときに onEndReached がトリガーされます。
  • loadMoreData 関数
    • loading または hasMorefalse の場合は、重複した読み込みや不要な読み込みを防ぐために早期リターンします。
    • setLoading(true) で読み込みを開始し、setTimeout で非同期API呼び出しをシミュレートします。
    • 新しいデータを生成し、setData(prevData => [...prevData, ...newItems]) を使って既存のデータに追加します。[...prevData, ...newItems] のようにスプレッド構文を使うことで、新しい配列が作成され、React が変更を検知して UI を更新できるようになります。
    • setLoading(false) で読み込みを終了します。
    • setHasMore(false) で、これ以上データがないことを示します。
  • useState で状態管理
    • data: FlatList に表示するアイテムの配列。
    • loading: データ読み込み中かどうかを示すブール値。
    • hasMore: さらに読み込むデータがあるかどうかを示すブール値。

onEndReached の初回呼び出し問題への対処

onEndReached がリスト表示直後に意図せず呼び出されるのを防ぐための一般的な方法です。

import React, { useState, useEffect, useRef } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';

const INITIAL_DATA_COUNT = 20;
const LOAD_MORE_COUNT = 10;

const generateData = (start, count) => {
  return Array.from({ length: count }, (_, i) => ({
    id: String(start + i),
    title: `アイテム ${start + i + 1}`,
  }));
};

const DebouncedInfiniteScroll = () => {
  const [data, setData] = useState(generateData(0, INITIAL_DATA_COUNT));
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const onEndReachedCalledDuringMomentum = useRef(true); // ★重要: useRef を使用

  const handleLoadMore = () => {
    // スクロールが停止してからonEndReachedが呼ばれることを期待する
    // ただし、Androidでは慣性スクロール中にonEndReachedが呼ばれることがあるため、このフラグで制御
    if (!onEndReachedCalledDuringMomentum.current && !loading && hasMore) {
      setLoading(true);
      setTimeout(() => {
        const newStartingIndex = data.length;
        const newItems = generateData(newStartingIndex, LOAD_MORE_COUNT);
        setData(prevData => [...prevData, ...newItems]);
        setLoading(false);

        if (data.length + newItems.length >= 50) {
          setHasMore(false);
        }
        console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
      }, 1500);
    }
  };

  const renderItem = ({ item }) => (
    <View style={styles.item}>
      <Text style={styles.title}>{item.title}</Text>
    </View>
  );

  const renderFooter = () => {
    if (!loading && hasMore) return null;
    if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
    return (
      <View style={styles.footer}>
        <ActivityIndicator size="large" />
        <Text>読み込み中...</Text>
      </View>
    );
  };

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      onEndReached={handleLoadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={renderFooter}
      // ★重要: スクロールが開始されたときにフラグをリセット
      onMomentumScrollBegin={() => {
        onEndReachedCalledDuringMomentum.current = false;
      }}
    />
  );
};

const styles = StyleSheet.create({
  item: {
    backgroundColor: '#f9f9f9',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 5,
    borderWidth: 1,
    borderColor: '#ddd',
  },
  title: {
    fontSize: 18,
  },
  footer: {
    paddingVertical: 20,
    borderTopWidth: 1,
    borderColor: '#ced0ce',
    alignItems: 'center',
    justifyContent: 'center',
  },
  noMoreDataText: {
    textAlign: 'center',
    paddingVertical: 20,
    color: '#888',
  },
});

export default DebouncedInfiniteScroll;

解説

  • onMomentumScrollBegin={() => { onEndReachedCalledDuringMomentum.current = false; }}
    • ユーザーがスクロールを開始し、慣性スクロールが始まる直前にこの関数が呼び出されます。
    • ここで onEndReachedCalledDuringMomentum.currentfalse に設定することで、「もう初回レンダリング時の不必要な呼び出しはスキップしなくて良い」 という状態にします。
    • これにより、実際のユーザーのスクロールによって onEndReached がトリガーされた場合にのみデータがロードされるようになります。
  • onEndReachedCalledDuringMomentum = useRef(true)
    • useRef を使用して、コンポーネントの再レンダリング時に値が保持されるミュータブルなオブジェクトを作成します。
    • 初期値 true は、「慣性スクロール中に onEndReached が呼び出されたとみなす」という状態を表します。これにより、初回レンダリング時には handleLoadMore 内の if 文でスキップされます。

リストのアイテム数が少なく、画面に収まってしまう場合でも onEndReached がトリガーされてしまうことがあります。これをより堅牢に制御したい場合の例です。

import React, { useState, useEffect, useRef } from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet, Dimensions } from 'react-native';

const INITIAL_DATA_COUNT_SHORT = 5; // 初期のアイテム数を少なく設定
const LOAD_MORE_COUNT = 10;

const generateData = (start, count) => {
  return Array.from({ length: count }, (_, i) => ({
    id: String(start + i),
    title: `アイテム ${start + i + 1}`,
  }));
};

const { height: screenHeight } = Dimensions.get('window');

const ShortListControl = () => {
  const [data, setData] = useState(generateData(0, INITIAL_DATA_COUNT_SHORT));
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const onEndReachedCalledDuringMomentum = useRef(true);
  const flatListRef = useRef(null); // FlatListへの参照

  const handleLoadMore = () => {
    if (!onEndReachedCalledDuringMomentum.current && !loading && hasMore) {
      setLoading(true);
      setTimeout(() => {
        const newStartingIndex = data.length;
        const newItems = generateData(newStartingIndex, LOAD_MORE_COUNT);
        setData(prevData => [...prevData, ...newItems]);
        setLoading(false);

        if (data.length + newItems.length >= 50) {
          setHasMore(false);
        }
        console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
      }, 1500);
    }
  };

  useEffect(() => {
    // 初回レンダリング後に、リストの高さとコンテンツの高さを比較する
    // コンテンツが画面に収まるほど短い場合にのみ、onEndReachedをトリガー
    // これにより、初回ロード時でもアイテム数が少ない場合に限り自動で追加ロードされる
    if (flatListRef.current && data.length > 0 && !loading) {
      const timer = setTimeout(() => {
        flatListRef.current.getScrollResponder().measureLayout(
          flatListRef.current.getScrollResponder().scrollResponderHandle,
          (x, y, width, height) => {
            // FlatListの高さよりコンテンツの高さが低い場合
            // (これはおおよそのチェックであり、正確なコンテンツ高さを取得するにはLayoutAnimationやonContentSizeChangeをより深く使う必要がある)
            if (height >= screenHeight) { // ここでは、FlatListが画面の高さ以上あると仮定
                // onEndReachedCalledDuringMomentumをtrueに戻して、初回トリガーを許可する
                // もし、リストが十分に長い場合は、手動でonEndReachedを呼び出さない
                onEndReachedCalledDuringMomentum.current = false;
            } else {
                // リストが短い場合は、onEndReachedをトリガーしてデータを追加
                if (hasMore && !loading) {
                    console.log('リストが短いので自動的に追加データをロードします。');
                    handleLoadMore(); // 直接呼び出す
                }
            }
          }
        );
      }, 100); // レイアウトが計算されるのを少し待つ
    }
  }, [data, loading, hasMore]); // データが更新されたり、ロード状態が変わったら再チェック

  const renderItem = ({ item }) => (
    <View style={styles.item}>
      <Text style={styles.title}>{item.title}</Text>
    </View>
  );

  const renderFooter = () => {
    if (!loading && hasMore) return null;
    if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
    return (
      <View style={styles.footer}>
        <ActivityIndicator size="large" />
        <Text>読み込み中...</Text>
      </View>
    );
  };

  return (
    <FlatList
      ref={flatListRef} // FlatListへの参照を設定
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      onEndReached={handleLoadMore}
      onEndReachedThreshold={0.5}
      ListFooterComponent={renderFooter}
      onMomentumScrollBegin={() => {
        onEndReachedCalledDuringMomentum.current = false;
      }}
      // onContentSizeChange={(contentWidth, contentHeight) => {
      //   // コンテンツの高さが変わったときに、もし画面内に収まってしまうなら
      //   // 再度onEndReachedをトリガーするなど、より高度な制御が可能
      //   // 例えば、contentHeight < screenHeight の場合に handleLoadMore を呼ぶ
      //   // ただし、無限ループにならないように注意が必要
      // }}
    />
  );
};

const styles = StyleSheet.create({
  item: {
    backgroundColor: '#f9f9f9',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 5,
    borderWidth: 1,
    borderColor: '#ddd',
  },
  title: {
    fontSize: 18,
  },
  footer: {
    paddingVertical: 20,
    borderTopWidth: 1,
    borderColor: '#ced0ce',
    alignItems: 'center',
    justifyContent: 'center',
  },
  noMoreDataText: {
    textAlign: 'center',
    paddingVertical: 20,
    color: '#888',
  },
});

export default ShortListControl;

解説

  • コメントアウトされた onContentSizeChange
    FlatList のコンテンツのサイズが変更されたときに呼び出されるプロパティです。これを活用して、リストのコンテンツが画面の高さより短い場合に、さらにデータを読み込む処理をトリガーすることも可能です。しかし、これも無限ループに陥らないように loadinghasMore フラグで厳密に制御する必要があります。
  • useEffect 内での高さチェック
    • useEffect を使用して、data が更新されるたびにリストの高さが画面に収まっているかを確認します。
    • flatListRef.current.getScrollResponder().measureLayout(...) を使うことで、FlatList のスクロール可能な領域の高さ(height)を取得できます。
    • もしリストの高さが画面の高さより短い場合、自動的に handleLoadMore() を呼び出してデータを追加します。これにより、ユーザーがスクロールしなくても画面がデータで埋められるようになります。
    • 注意点
      measureLayout はレイアウトが完了した後でなければ正確な値を取得できません。setTimeout で少し待つなどの工夫が必要です。また、コンテンツの正確な高さを取得するには onContentSizeChange と組み合わせて使うとより確実です。ただし、この方法は複雑になりがちで、無限ループのリスクも伴うため、慎重な実装とテストが必要です。
  • flatListRef = useRef(null)
    FlatList コンポーネントに直接アクセスするための ref を作成します。

これらの例は、FlatList#onEndReached を効果的に使用するための基本的なパターンと、よく遭遇する問題に対する堅牢な解決策を示しています。

  • リストのアイテム数が少ない場合の自動ロードは、useEffectonContentSizeChange を使った少し高度な制御が必要になります。
  • onEndReachedCalledDuringMomentum フラグ (useRefonMomentumScrollBegin を組み合わせる) は、初回レンダリング時の不必要なトリガーを防ぐために非常に有効です。
  • loading フラグでの重複呼び出し防止は必須です。


ScrollView と onScroll (手動実装)

FlatList の代わりにネイティブの ScrollView コンポーネントを使用し、onScroll イベントを監視してスクロール位置を手動で計算することで、無限スクロールを実装できます。

メリット

  • シンプルなリストや、仮想化が不要な場合に、コンポーネント数を減らせる。
  • FlatList よりも低レベルな制御が可能。

デメリット

  • 実装の複雑さ: スクロール位置の計算 (event.nativeEvent.contentOffset.y, event.nativeEvent.contentSize.height, event.nativeEvent.layoutMeasurement.height) や、データの重複ロード防止、初回ロード時の問題回避など、すべて手動で実装する必要がある。
  • パフォーマンス: FlatList のような仮想化機能がないため、アイテム数が増えるとメモリ使用量が増え、パフォーマンスが低下する可能性がある。

コード例

import React, { useState } from 'react';
import { ScrollView, Text, View, ActivityIndicator, StyleSheet, Dimensions } from 'react-native';

const INITIAL_DATA_COUNT = 20;
const LOAD_MORE_COUNT = 10;
const { height: screenHeight } = Dimensions.get('window');

const generateData = (start, count) => {
  return Array.from({ length: count }, (_, i) => ({
    id: String(start + i),
    title: `アイテム ${start + i + 1}`,
  }));
};

const ScrollViewInfiniteScroll = () => {
  const [data, setData] = useState(generateData(0, INITIAL_DATA_COUNT));
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const handleScroll = (event) => {
    const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
    const paddingToBottom = 20; // 底部からどれくらいの距離でトリガーするか

    // スクロールの終わりに近いかどうかをチェック
    const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;

    if (isCloseToBottom && !loading && hasMore) {
      loadMoreData();
    }
  };

  const loadMoreData = () => {
    setLoading(true);
    setTimeout(() => {
      const newStartingIndex = data.length;
      const newItems = generateData(newStartingIndex, LOAD_MORE_COUNT);
      setData(prevData => [...prevData, ...newItems]);
      setLoading(false);

      if (data.length + newItems.length >= 50) {
        setHasMore(false);
      }
      console.log('データを追加しました。現在のアイテム数:', data.length + newItems.length);
    }, 1500);
  };

  const renderFooter = () => {
    if (!loading && hasMore) return null;
    if (!hasMore) return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
    return (
      <View style={styles.footer}>
        <ActivityIndicator size="large" />
        <Text>読み込み中...</Text>
      </View>
    );
  };

  return (
    <ScrollView
      style={styles.container}
      onScroll={handleScroll}
      scrollEventThrottle={16} // パフォーマンスのためにイベントの発火頻度を調整
    >
      {data.map((item) => (
        <View key={item.id} style={styles.item}>
          <Text style={styles.title}>{item.title}</Text>
        </View>
      ))}
      {renderFooter()}
    </ScrollView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  item: {
    backgroundColor: '#f9f9f9',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 5,
    borderWidth: 1,
    borderColor: '#ddd',
  },
  title: {
    fontSize: 18,
  },
  footer: {
    paddingVertical: 20,
    borderTopWidth: 1,
    borderColor: '#ced0ce',
    alignItems: 'center',
    justifyContent: 'center',
  },
  noMoreDataText: {
    textAlign: 'center',
    paddingVertical: 20,
    color: '#888',
  },
});

export default ScrollViewInfiniteScroll;

VirtualizedList (より低レベルな仮想化リスト)

FlatList は内部で VirtualizedList を利用しています。VirtualizedList を直接使用することで、FlatList が提供する一部の抽象化を取り除き、より細かな制御が可能になります。これは通常、FlatList が特定のユースケースで十分でない場合に検討されます。

メリット

  • データの取得方法やビューのリサイクルロジックをより細かくカスタマイズできる。
  • FlatList と同等の仮想化機能を持つ。

デメリット

  • FlatList よりも実装が複雑。特に getItemgetItemCount といったメソッドの実装が必要。

これは非常に特殊なケースでしか使用されません。ほとんどの場合、FlatList で十分です。

専用の無限スクロールライブラリやコンポーネント

コミュニティには、無限スクロールのロジックをより簡単に扱えるように設計されたライブラリが存在します。これらのライブラリは、onEndReached の一般的な落とし穴(初回ロード、重複呼び出しなど)を内部で処理してくれる場合があります。

例: react-native-infinite-scrollview (現在はあまりメンテナンスされていないかもしれません)

これらのライブラリは、特定のユースケースに特化しているため、プロジェクトの要件に合致すれば開発コストを削減できる可能性があります。ただし、メンテナンス状況やコミュニティの活発さを確認することが重要です。

react-query や swr などのデータフェッチライブラリとの連携

React Native の無限スクロールの課題は、単に onEndReached をトリガーするだけでなく、その後のデータのフェッチ、キャッシュ、更新、エラーハンドリングなど、データ管理全体に関わります。react-queryswr のようなデータフェッチライブラリは、これらのデータ管理の側面を大幅に簡素化できます。

これらのライブラリは、無限スクロールのパターン(ページネーション、カーソルベースの取得など)を直接サポートしていることが多く、onEndReached と組み合わせることで、より堅牢で効率的な実装が可能です。

react-query を使用した例(概念)

import React from 'react';
import { FlatList, Text, View, ActivityIndicator, StyleSheet } from 'react-native';
import { useInfiniteQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query'; // v4以降のreact-query

const queryClient = new QueryClient();

// API呼び出しをシミュレート
const fetchItems = async ({ pageParam = 0 }) => {
  console.log(`Fetching page: ${pageParam}`);
  const items = Array.from({ length: 10 }, (_, i) => ({
    id: `item-${pageParam * 10 + i}`,
    title: `アイテム ${pageParam * 10 + i + 1}`,
  }));
  // 最後に到達したかどうかを判断するロジック
  const hasMore = pageParam < 4; // 例: 5ページまでデータがあるとする
  await new Promise(resolve => setTimeout(resolve, 1000)); // API遅延をシミュレート
  return { items, nextPage: hasMore ? pageParam + 1 : undefined };
};

const InfiniteScrollWithReactQuery = () => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    error,
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: fetchItems,
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 0,
  });

  const allItems = data?.pages.flatMap(page => page.items) || [];

  if (isLoading) {
    return (
      <View style={styles.centered}>
        <ActivityIndicator size="large" />
        <Text>初期データをロード中...</Text>
      </View>
    );
  }

  if (isError) {
    return (
      <View style={styles.centered}>
        <Text>エラーが発生しました: {error.message}</Text>
      </View>
    );
  }

  const renderItem = ({ item }) => (
    <View style={styles.item}>
      <Text style={styles.title}>{item.title}</Text>
    </View>
  );

  const renderFooter = () => {
    if (isFetchingNextPage) {
      return (
        <View style={styles.footer}>
          <ActivityIndicator size="large" />
          <Text>さらに読み込み中...</Text>
        </View>
      );
    }
    if (!hasNextPage) {
      return <Text style={styles.noMoreDataText}>全てのデータを表示しました</Text>;
    }
    return null;
  };

  return (
    <FlatList
      data={allItems}
      keyExtractor={(item) => item.id}
      renderItem={renderItem}
      onEndReached={() => {
        if (hasNextPage) {
          fetchNextPage(); // next pageがある場合のみフェッチ
        }
      }}
      onEndReachedThreshold={0.5}
      ListFooterComponent={renderFooter}
    />
  );
};

const App = () => (
  <QueryClientProvider client={queryClient}>
    <InfiniteScrollWithReactQuery />
  </QueryClientProvider>
);

const styles = StyleSheet.create({
  centered: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  item: {
    backgroundColor: '#f9f9f9',
    padding: 20,
    marginVertical: 8,
    marginHorizontal: 16,
    borderRadius: 5,
    borderWidth: 1,
    borderColor: '#ddd',
  },
  title: {
    fontSize: 18,
  },
  footer: {
    paddingVertical: 20,
    borderTopWidth: 1,
    borderColor: '#ced0ce',
    alignItems: 'center',
    justifyContent: 'center',
  },
  noMoreDataText: {
    textAlign: 'center',
    paddingVertical: 20,
    color: '#888',
  },
});

export default App;
  • デバッグツールの充実
    React Query Devtools を使うことで、キャッシュの状態やクエリの状況を視覚的に確認できます。
  • エラーハンドリング
    エラー状態を簡単に管理できます。
  • 自動再フェッチ
    ネットワーク接続が戻った際や、ウィンドウフォーカス時に自動でデータを更新します。
  • 強力なキャッシュ機能
    データを効率的にキャッシュし、オフラインアクセスや高速なUIを実現します。
  • 無限スクロールのロジックを簡素化
    useInfiniteQuery フックが次ページの取得、キャッシュ、ローディング状態などを自動で管理してくれます。
  • FlatList のパフォーマンス限界に直面している、または非常に特殊なリストの要件がある場合
    VirtualizedList や、FlashList (Shopify 製) のような高性能リストコンポーネントの検討が必要になるかもしれません。
  • 複雑なデータフェッチ、キャッシュ、UI同期が必要な場合
    react-queryswr のようなデータフェッチライブラリと FlatList を組み合わせるのが最適です。これにより、コードの保守性が向上し、多くのエッジケースに対処できます。
  • 小規模でシンプル、かつ仮想化が不要なリスト
    ScrollViewonScroll を手動で実装することも可能です。
  • ほとんどのケース
    FlatListonEndReached を使用し、上述の「一般的なエラーとトラブルシューティング」で説明した対策(loading フラグ、onMomentumScrollBeginuseRef)を講じるのが最もバランスが取れています。