← Назад к вопросам

Какие проблемы возникают при бесконечном scroll на React?

2.0 Middle🔥 221 комментариев
#React

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Проблемы бесконечного скролла в React

Это сложная фича которая имеет много подводных камней. Разберём проблемы и решения.

Проблема 1: Memory Leak (утечка памяти)

// ПЛОХО - DOM растёт бесконечно
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    // ОШИБКА: никогда не удаляем слушателя!
    // При размонтировании компонента слушатель остаётся
  }, []);

  const handleScroll = () => {
    if (isNearBottom()) {
      fetchMore();
    }
  };

  const fetchMore = async () => {
    const newItems = await fetch(`/api/items?page=${page}`);
    setItems([...items, ...newItems]); // УТЕЧКА: items растёт бесконечно
    setPage(page + 1);
  };

  return (
    <div>
      {items.map(item => <ItemComponent key={item.id} {...item} />)}
    </div>
  );
}

// ХОРОШО - правильно управляем слушателями
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    const handleScroll = () => {
      if (isNearBottom()) {
        fetchMore();
      }
    };

    window.addEventListener('scroll', handleScroll);
    
    // ПРАВИЛЬНО: удаляем слушателя при размонтировании
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [page, items]);

  const fetchMore = async () => {
    const newItems = await fetch(`/api/items?page=${page}`);
    setItems(prev => [...prev, ...newItems]);
    setPage(prev => prev + 1);
  };

  return <div>{items.map(item => <ItemComponent key={item.id} {...item} />)}</div>;
}

Проблема 2: Множественные запросы (Race Condition)

// ПЛОХО - может отправить несколько запросов одновременно
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  const handleScroll = () => {
    if (isNearBottom() && !isLoading) { // НЕДОСТАТОЧНО!
      fetchMore();
    }
  };

  const fetchMore = async () => {
    setIsLoading(true);
    // ПРОБЛЕМА: если scroll быстро сработал дважды,
    // оба запроса начнут одновременно
    const response = await fetch(`/api/items`);
    const newItems = await response.json();
    setItems(prev => [...prev, ...newItems]);
    setIsLoading(false);
  };
}

// ХОРОШО - использруй flag для предотвращения
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const isFetchingRef = useRef(false);

  const handleScroll = () => {
    if (isNearBottom() && !isFetchingRef.current) {
      fetchMore();
    }
  };

  const fetchMore = async () => {
    if (isFetchingRef.current) return; // двойная проверка
    
    isFetchingRef.current = true;
    setIsLoading(true);
    
    try {
      const response = await fetch(`/api/items`);
      const newItems = await response.json();
      setItems(prev => [...prev, ...newItems]);
    } finally {
      isFetchingRef.current = false;
      setIsLoading(false);
    }
  };
}

Проблема 3: Производительность (Rendering Performance)

// ПЛОХО - перерендеривает ВСЕ элементы
function InfiniteScroll() {
  const [items, setItems] = useState([]);

  return (
    <div>
      {/* Каждый скролл перерендеривает все 1000 элементов! */}
      {items.map(item => (
        <div key={item.id}>
          <h3>{item.title}</h3>
          <p>{item.description}</p>
          <img src={item.image} alt={item.title} />
        </div>
      ))}
    </div>
  );
}

// ХОРОШО - виртуализируй список
import { FixedSizeList } from 'react-window';

function InfiniteScroll() {
  const [items, setItems] = useState([]);

  const Row = ({ index, style }) => (
    <div style={style}>
      <ItemComponent item={items[index]} />
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={35}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

// react-window только рендеривает видимые элементы
// Не все 10000 элементов сразу!

Проблема 4: Обнаружение конца страницы

// ПЛОХО - неточное обнаружение
function InfiniteScroll() {
  const isNearBottom = () => {
    return window.innerHeight + window.scrollY >= document.body.offsetHeight;
  };
  // ПРОБЛЕМА: может не сработать на Edge cases
}

// ХОРОШО - используй Intersection Observer
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const bottomRef = useRef(null);

  useEffect(() => {
    // Intersection Observer более надёжен
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) {
          fetchMore();
        }
      },
      { threshold: 0.1 }
    );

    if (bottomRef.current) {
      observer.observe(bottomRef.current);
    }

    return () => {
      if (bottomRef.current) {
        observer.unobserve(bottomRef.current);
      }
    };
  }, []);

  return (
    <div>
      {items.map(item => <ItemComponent key={item.id} item={item} />)}
      <div ref={bottomRef}>Загружаю ещё...</div>
    </div>
  );
}

Проблема 5: Дублирование элементов

// ПЛОХО - может быть дублирование
function InfiniteScroll() {
  const [items, setItems] = useState([]);

  const fetchMore = async () => {
    const newItems = await fetch('/api/items');
    // ПРОБЛЕМА: если запрос вернулся в неправильном порядке
    // может быть дублирование
    setItems([...items, ...newItems]);
  };
}

// ХОРОШО - отслеживай последний ID
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const [lastId, setLastId] = useState(null);

  const fetchMore = async () => {
    const newItems = await fetch(`/api/items?after=${lastId}`);
    setItems(prev => [...prev, ...newItems]);
    setLastId(newItems[newItems.length - 1]?.id);
  };
}

Проблема 6: Scroll Position потеряется

// ПЛОХО - скролл сбросится при обновлении
function InfiniteScroll() {
  const [items, setItems] = useState([]);

  // При каждом обновлении items скролл может сбросится
  return <div>{items.map(item => <Item key={item.id} item={item} />)}</div>;
}

// ХОРОШО - сохраняй scroll position
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const containerRef = useRef(null);
  const scrollPosRef = useRef(0);

  const handleScroll = (e) => {
    scrollPosRef.current = e.target.scrollTop;
  };

  const fetchMore = async () => {
    const currentScroll = scrollPosRef.current;
    
    const newItems = await fetch('/api/items');
    setItems(prev => [...prev, ...newItems]);
    
    // Восстанавливаем scroll position
    setTimeout(() => {
      if (containerRef.current) {
        containerRef.current.scrollTop = currentScroll;
      }
    }, 0);
  };

  return (
    <div ref={containerRef} onScroll={handleScroll}>
      {items.map(item => <Item key={item.id} item={item} />)}
    </div>
  );
}

Проблема 7: Network errors

// ПЛОХО - не обработали ошибку
function InfiniteScroll() {
  const fetchMore = async () => {
    const response = await fetch('/api/items');
    const newItems = await response.json();
    setItems(prev => [...prev, ...newItems]);
  };
}

// ХОРОШО - обработай ошибки
function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  const fetchMore = async () => {
    setIsLoading(true);
    setError(null);
    
    try {
      const response = await fetch('/api/items');
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      
      const newItems = await response.json();
      setItems(prev => [...prev, ...newItems]);
    } catch (err) {
      setError(err.message);
      console.error('Ошибка загрузки:', err);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      {error && <div className="error">Ошибка: {error}</div>}
      {items.map(item => <Item key={item.id} item={item} />)}
      {isLoading && <div>Загружаю...</div>}
    </div>
  );
}

Полный пример: правильная реализация

function InfiniteScroll() {
  const [items, setItems] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);
  const [page, setPage] = useState(1);
  const isFetchingRef = useRef(false);
  const bottomRef = useRef(null);

  // Используй Intersection Observer
  useEffect(() => {
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting && hasMore && !isLoading && !isFetchingRef.current) {
          fetchMore();
        }
      },
      { threshold: 0.1 }
    );

    if (bottomRef.current) observer.observe(bottomRef.current);

    return () => {
      if (bottomRef.current) observer.unobserve(bottomRef.current);
    };
  }, [hasMore, isLoading]);

  const fetchMore = async () => {
    if (isFetchingRef.current) return;
    
    isFetchingRef.current = true;
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(`/api/items?page=${page}`);
      
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      
      const { items: newItems, hasMore: more } = await response.json();
      
      setItems(prev => [...prev, ...newItems]);
      setHasMore(more);
      setPage(prev => prev + 1);
    } catch (err) {
      setError(err.message);
    } finally {
      isFetchingRef.current = false;
      setIsLoading(false);
    }
  };

  return (
    <div>
      {error && <div className="error">Ошибка: {error}</div>}
      
      {items.length === 0 && !isLoading && (
        <div>Нет элементов</div>
      )}
      
      <div className="items-list">
        {items.map(item => (
          <ItemComponent key={item.id} item={item} />
        ))}
      </div>
      
      {isLoading && <div className="loader">Загружаю...</div>}
      
      {!hasMore && <div>Больше нет элементов</div>}
      
      {/* Сентинель для обнаружения конца */}
      <div ref={bottomRef} style={{ height: '20px' }} />
    </div>
  );
}

Чеклист при реализации infinite scroll

[ ] Используешь Intersection Observer?
[ ] Правильно управляешь слушателями (cleanup)?
[ ] Предотвращаешь множественные запросы?
[ ] Обработал ошибки?
[ ] Показываешь loading состояние?
[ ] Виртуализировал список (для больших данных)?
[ ] Сохраняешь scroll position?
[ ] Проверил на дублирование элементов?
[ ] Показываешь "конец списка"?
[ ] Тестировал на медленной сети?

Итого

Основные проблемы infinite scroll:

  1. Memory leak - очищай слушателей
  2. Race conditions - проверяй isLoading
  3. Производительность - виртуализируй списки
  4. Обнаружение - используй IntersectionObserver
  5. Дублирование - отслеживай ID
  6. Scroll position - сохраняй и восстанавливай
  7. Error handling - обработай ошибки сети
  8. Edge cases - проверь конец списка, пустой список

Используй библиотеку react-infinite-scroll-component если нужно простое решение!