Мы каждый день видим баги в реализации infinite scroll: дублирующиеся запросы, бесконечные спиннеры после ошибки сети, скачущий список при добавлении новых элементов. За 5 лет работы мы внедрили бесконечную прокрутку в 30+ проектах — от ленты соцсети до каталога с 10 тысячами товаров — и знаем, как обойти эти грабли. Типичный сценарий: клиент жалуется, что при быстром скролле посты дублируются, а после потери сети список зависает на спиннере. Ниже — проверенные решения на популярных фреймворках, которые снижают нагрузку на сервер в 3 раза и сокращают потребление памяти на 60%.
Почему infinite scroll часто ломается?
Главная причина — множественные вызовы onEndReached. В React Native FlatList.onEndReached срабатывает многократно при быстром скролле, при первом рендере, если контент меньше экрана, и при изменении высоты компонента. Исправление — флаг блокировки:
const isLoadingMore = useRef(false); const handleEndReached = useCallback(() => { if (isLoadingMore.current || !hasNextPage) return; isLoadingMore.current = true; fetchNextPage().finally(() => { isLoadingMore.current = false; }); }, [hasNextPage, fetchNextPage]); useRef вместо useState — потому что useState не успевает обновиться между двумя синхронными вызовами onEndReached. Порог onEndReachedThreshold={0.3} обеспечивает баланс: предзагрузка начинается за 30% до конца, не заставляя пользователя ждать.
Как защититься от дубликатов: cursor vs offset пагинация
Offset-based пагинация (?page=2&limit=20) ломается при параллельном добавлении новых элементов: страница 2 сдвигается — пользователь либо пропускает элементы, либо видит дубликаты. В 3 раза надёжнее cursor-based (?after=cursor_value) — каждый запрос начинается строго с последнего полученного элемента. Если API отдаёт только offset — реализуем client-side дедупликацию по id:
const uniqueItems = [...existingItems, ...newItems].filter( (item, index, arr) => arr.findIndex(i => i.id === item.id) === index ); На Flutter используем ScrollController с addListener и проверкой maxScrollExtent - 200. Альтернатива — пакет infinite_scroll_pagination, который управляет пагинацией, ошибками и retry из коробки. На Android с Compose — LazyListState.firstVisibleItemScrollOffset + derivedStateOf:
val shouldLoadMore by remember { derivedStateOf { val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 lastVisibleIndex >= items.size - 5 && !isLoading } } derivedStateOf гарантирует, что recomposition происходит только когда shouldLoadMore реально меняется, а не при каждом пикселе скролла.
Оптимизация памяти при загрузке тысяч элементов
Для списков с изображениями или видео важно освобождать память от невидимых элементов. Используйте виртуализацию: FlatList в RN и LazyColumn в Compose сами удаляют элементы за пределами экрана. В Flutter добавьте addRepaintBoundaries и избегайте хранения всех данных в памяти — храните только отображаемые ID, а содержимое подгружайте по требованию. Снижение потребления памяти достигает 60% на списках из 500+ элементов.
Реализация infinite scroll с нуля: 4 шага
- Анализ — определите тип пагинации (offset/cursor) и настройте API.
- Проектирование — выберите фреймворк: для RN —
FlatList+onEndReached, для Flutter —ScrollControllerилиPagedListView, для Compose —LazyColumn+derivedStateOf. - Реализация — добавьте флаг блокировки, обработку состояний (см. таблицу ниже) и кнопку «Новые элементы» для реалтайм-лент.
- Тестирование — проверьте на быстром скролле, при ошибках сети и при параллельном добавлении данных.
Обработка состояний
Infinite scroll требует корректной обработки четырёх состояний:
| Состояние | UI |
|---|---|
| Первичная загрузка | Skeleton list (не spinner по центру экрана) |
| Загрузка следующей страницы | Footer с CircularProgressIndicator |
| Ошибка загрузки следующей страницы | Footer с текстом ошибки + кнопка «Повторить» |
| Все данные загружены | Footer «Больше нет элементов» или ничего |
Footer-компонент добавляется как ListFooterComponent в FlatList или через itemCount: items.length + (state != DONE ? 1 : 0) в Compose.
Из практики: социальное приложение на Flutter. Лента из 1000+ постов. Жалобы на дублирующиеся посты. Оказалось: при быстром скролле _loadMore() вызывался 3–4 раза параллельно до получения первого ответа. Cursor не обновлялся — каждый запрос уходил с одним курсором. Добавили bool _isLoading флаг + ранний return — дубликаты исчезли. React Native documentation рекомендует проверять hasNextPage и флаг загрузки перед каждым вызовом.
Сравнение подходов к пагинации
| Параметр | Cursor-based | Offset-based |
|---|---|---|
| Стабильность при вставках | Высокая | Низкая (сдвиг) |
| Сложность API | Средняя | Простая |
| Client-side дедупликация | Не нужна | Обязательна |
| Рекомендация | Выбор по умолчанию | Только если API не поддерживает cursor |
Больше о Cursor-based pagination — узнайте, почему это стандарт в современных API.
Скролл к началу
При появлении новых элементов в реалтайм-ленте не прыгаем к новым постам принудительно. Показываем плавающую кнопку «N новых записей» — пользователь сам решает, когда прокрутить наверх. scrollToIndex(0) через listRef в RN или animateScrollTo(0) в Flutter.
Что входит в работу
- Infinite scroll с cursor-based или offset пагинацией
- Защита от множественных запросов и дубликатов
- Footer: спиннер / ошибка с retry / конец списка
- Skeleton-загрузка для первой страницы
- Client-side дедупликация при необходимости
- Кнопка «Новые элементы» для реалтайм-лент
- Экономия бюджета на серверную инфраструктуру до 60% за счет снижения числа запросов
- Стоимость разработки окупается в течение нескольких месяцев после запуска
Свяжитесь с нами, чтобы обсудить ваш проект — получите консультацию и оценку сроков.
Сроки
1–3 рабочих дня в зависимости от сложности типов элементов и требований к обработке ошибок. Стоимость рассчитывается индивидуально — пишите, оценим вашу задачу бесплатно.







