React Native

Списки та віртуалізація

FlatList, SectionList, FlashList, pull-to-refresh, порожній і помилковий стан, пагінація UI — каталог книг і стрічки поїздок/місць у Nomad

Списки та віртуалізація

Навіщо ця стаття

Уявіть стрічку поїздок: не три картки «для демо», а сотні записів за роки — з обкладинками, датами, короткими описами. Користувач гортає пальцем униз у метро. Якщо застосунок одразу створює усі картки в пам’яті (як array.map усередині ScrollView), телефон починає «заїкатися»: довге перше відкриття, ривки при скролі, нагрів, інколи навіть закриття застосунку системою.

У статті про базові компоненти ми вже розвели ролі: ScrollView — для невеликого прокручуваного контенту (форма, стаття, 3–5 карток). Офіційна документація React Native прямо радить: для довгих однотипних списків брати FlatList (або сильніший аналог), а не мапити все в ScrollView.

Ця стаття — про те, як малювати довгі списки так, щоб телефон тримав лише «вікно» рядків біля екрана. Це і є віртуалізація (virtualization).

Після статті ви зможете:

  1. Пояснити різницю між ScrollView + map і віртуалізованим списком.
  2. Зібрати FlatList з data, renderItem, keyExtractor.
  3. Показати порожній стан (ListEmptyComponent), шапку/підвал списку, pull-to-refresh.
  4. Зрозуміти UI пагінації (onEndReached) — без справжнього API (мережа буде пізніше).
  5. Згрупувати дані в SectionList (наприклад, книги за жанром).
  6. Знати, навіщо FlashList і коли його ставити замість FlatList.
  7. У Nomad — віртуалізована стрічка поїздок + горизонтальний список місць.
Головна думка. Довгий список на телефоні не малює всі тисячі рядків одразу. Він тримає в дереві React лише ті, що (майже) видно на екрані, плюс невеликий запас зверху й знизу. Коли ви скролите, старі рядки знімаються (або переробляються під нові дані), а нові з’являються. Користувач бачить нескінченну стрічку; телефон — лише «вікно» в масив.
Живі прев’ю. Демо нижче — через ::react-native-preview (react-native-web). FlatList / SectionList / RefreshControl у прев’ю працюють. FlashList у iframe немає (окремий пакет) — його розбираємо в тексті й підключаємо в Nomad / міні-проєкті на пристрої. Скрол у браузері зручніший мишею/трекпадом; «рідний» feel — у Expo Go.

Місток з web React

У браузеріУ React NativeКоментар
<ul>{items.map(…)}</ul> на сторінціScrollView + mapУсе в DOM / у нативному дереві одразу
window + document scrollScrollViewОдна довга «сторінка» контенту
react-window, react-virtualized, TanStack VirtualFlatList / SectionList / FlashListВіртуалізація: малюємо лише visible (+ buffer)
Infinite scroll (IntersectionObserver)onEndReachedПідвантаження, коли близько до кінця
Pull-to-refresh (мобільні веб-патерни)RefreshControl«Потягнути вниз — оновити»
Web-звичка. Написати {trips.map(t => <TripCard … />)} всередині ScrollView і вважати, що «це ж той самий React». Для 3 карток — нормально. Для 200 — ви створюєте 200 нативних view з картинками до першого кадру. На слабкому Android це відчувається одразу.

Що ламається без віртуалізації

Життєва сцена

Ви відкрили «Мандрівник». На екрані видно дві картки поїздок. Решта — нижче, за межами екрана. Питання: чи потрібно телефону зараз тримати в пам’яті ще 198 карток з Image?

Якщо відповідь «так, бо ми зробили map» — ви платите CPU і RAM за те, чого користувач не бачить.

ScrollView + map — що відбувається

1. Завантаження екрана

React будує усі дочірні елементи масиву. Кожна картка — окремий піддерево (Pressable, Image, тексти).

2. Layout

Нативний шар рахує висоту всього вмісту (навіть за екраном), щоб знати, наскільки довгий скрол.

3. Скрол

Пам’ять уже зайнята; під час скролу ще йдуть оновлення зображень, тіні, перерахунки — телефон «пихтить».

4. Що бачить користувач

Ривки, білі спалахи, довгий cold start. Людина не каже «віртуалізація погана» — каже «додаток гальмує».

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

rectangle "ScrollView + map" as S {
  rectangle "Картка 1 (на екрані)" as C1
  rectangle "Картка 2 (на екрані)" as C2
  rectangle "Картка 3 … 200\n(усі в пам'яті)" as C3
}

rectangle "FlatList / FlashList" as F {
  rectangle "Буфер зверху" as B1
  rectangle "Видимі рядки" as V
  rectangle "Буфер знизу" as B2
  rectangle "Решта даних\nлише в масиві JS" as Rest
}

note right of S
  Усі рядки = React-елементи
end note

note right of F
  У дереві — лише вікно
end note

@enduml

Віртуалізація простими словами

Віртуалізація (virtualization) — техніка, коли UI не створює одночасно всі елементи довгого списку, а малює лише ті, що потрібні для поточного положення скролу (плюс невеликий буфер поза екраном, щоб скрол був плавним).

Аналогія з вебом: якщо ви колись підключали react-window до таблиці на 10 000 рядків — ідея та сама. У React Native «дефолтний» інструмент для цього — FlatList (під капотом — VirtualizedList).

data
масив
Джерело правди: усі елементи списку в JavaScript. Це не означає, що всі вони змонтовані як UI.
вікно (window)
поняття
Підмножина data, для якої зараз існують нативні view. Рухається разом зі скролом.
recycle / reuse
ідея
Деякі списки (зокрема FlashList) перевикористовують уже створені рядки: знімають старі props і підставляють нові дані, замість знищити компонент і створити новий. Менше алокацій — плавніший скрол.
key / keyExtractor
ідентичність
Стабільний ідентифікатор рядка (краще id з даних, не індекс масиву). React і список мають розуміти «це той самий trip, лише зсунувся», а не «зовсім новий елемент».

FlatList — базовий віртуалізований список

FlatList — компонент React Native для відображення довгого прокручуваного списку однотипних елементів з віртуалізацією. Ви передаєте масив і функцію «як намалювати один елемент»; список сам вирішує, які елементи зараз живуть у дереві.

Мінімальний каркас:

import { FlatList, Text, View } from 'react-native';

type Item = { id: string; title: string };

const DATA: Item[] = [
  { id: '1', title: 'Перший' },
  { id: '2', title: 'Другий' },
  { id: '3', title: 'Третій' },
];

export function SimpleList() {
  return (
    <FlatList
      data={DATA}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View>
          <Text>{item.title}</Text>
        </View>
      )}
    />
  );
}

Анатомія props

data
ArrayLike
Масив (або масивоподібний) елементів. Коли data змінюється за посиланням і вмістом — список оновлюється. Для «виділили рядок» див. extraData.
renderItem
({ item, index, separators }) => ReactElement
Функція, яка малює один рядок. Викликається для елементів у вікні (і при recycle). Тримайте її стабільною (useCallback) або виносьте компонент рядка назовні.
keyExtractor
(item, index) => string
Повертає унікальний ключ. За замовчуванням FlatList шукає item.key або item.id. Краще явнийkeyExtractor, якщо поле інше.
ListEmptyComponent
component | element
Що показати, коли data порожній (або довжина 0). Порожній стан — частина UX, не «білий екран».
ListHeaderComponent / ListFooterComponent
component | element
Шапка й підвал всередині скролу (заголовок секції, фільтри, спінер «завантажуємо ще…»).
ItemSeparatorComponent
component
Розділювач між рядками (лінія, відступ). Зручніше, ніж marginBottom на кожній картці з «у останньої нуль».
contentContainerStyle
style
Стиль внутрішнього контейнера вмісту (padding списку). Як у ScrollView: не плутати зі style самої «рамки» списку.
style
style
Стиль зовнішнього контейнера списку. Часто flex: 1, щоб зайняти місце між шапкою екрана і sticky-кнопкою.
extraData
any
Якщо рядок залежить від стану позаitem (наприклад, selectedId), передайте цей стан у extraData, інакше FlatList може не перемалювати рядки (він оптимізує оновлення).
refreshing + onRefresh / refreshControl
refresh
Pull-to-refresh: або пара props refreshing / onRefresh, або повний <RefreshControl /> через refreshControl={…}.
onEndReached + onEndReachedThreshold
pagination UI
Колбек, коли скрол близько до кінця. threshold — частка видимої довжини (наприклад 0.3 ≈ «за 30% екрана до кінця»). Для «підвантажити ще сторінку».
horizontal
boolean
Горизонтальний список (чіпи, «останні місця», карусель). Тоді «кінець» — праворуч.

Демо: простий FlatList

TSXFlatListBasic.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Bounded height. Як і ScrollView, список потребує обмеженої висоти батька (flex: 1 у ланцюжку Screen → область списку). Якщо батько «висота за контентом», віртуалізація ламається або скрол не з’являється.

keyExtractor: чому не індекс

// Погано для динамічних списків
keyExtractor={(_, index) => String(index)}

// Добре
keyExtractor={(item) => item.id}

Що бачить користувач при «поганих» ключах: після видалення/вставки рядка «стрибають» стани (розкрита картка, локальний useState у рядку), картинки «липнуть» не до тих об’єктів, анімації дивні.

Правило: ключ = стабільна бізнес-ідентичність (trip.id), не позиція в масиві.

Не ставте key={…} вручну на корені того, що повертає renderItem «замість» keyExtractor. Список сам керує ключами через keyExtractor. Дублювання плутає recycle (особливо у FlashList).

Порожній стан, шапка, розділювачі

Порожній список — не баг. Це нормальний екран: «Поки немає поїздок» + підказка, що робити далі.

TSXListEmpty.tsx
Android
9:41

Loading…

react-native-web · not a real device

contentContainerStyle + empty. Щоб порожній стан вирівняти по центру вертикально, часто ставлять contentContainerStyle={{ flexGrow: 1 }} (коли data.length === 0). Інакше empty «прилипає» до верху скролу.

Pull-to-refresh (RefreshControl)

Pull-to-refresh — жест «потягнути список вниз і відпустити», після якого застосунок оновлює дані. На iOS звичний спінер зверху; на Android — свій Material-індикатор.

У React Native це компонент RefreshControl, який передають у refreshControl у ScrollView / FlatList / SectionList (вертикальний скрол).

refreshing
boolean
Чи зараз іде оновлення. true — крутиться індикатор, навіть якщо жест уже відпущено.
onRefresh
() => void
Колбек після жесту. Тут ви ставите refreshing = true, вантажите дані, потім false.
tintColor / colors
стиль
iOS: tintColor. Android: colors={[…]} — колір індикатора під ваш primary.

Демо: оновлення списку

TSXPullRefresh.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

У прев’ю потягніть список униз (на тачпаді / з зажатою кнопкою миші). На реальному телефоні жест природніший.

Мережевий шар (справжній GET, помилки, offline) — у статті про networking. Тут важливий UI-контракт: refreshing чесний, індикатор зникає, дані оновлюються або показується помилка.


Пагінація UI: onEndReached

Пагінація — підвантаження даних порціями (сторінками), а не «усі 10 000 з сервера одразу».

На мобільному UX часто:

  1. Перша порція (наприклад, 20 елементів).
  2. Користувач доскролив майже до кінця.
  3. Показуємо footer «Завантаження…».
  4. Дописуємо наступну порцію в data.
  5. Якщо сервер сказав «більше немає» — footer зникає / «Це все».

onEndReached викликається, коли скрол наближається до кінця списку. onEndReachedThreshold — наскільки рано (частка visible length).

Обережно з подвійними викликами. Під час швидкого скролу або зміни data колбек може спрацювати знову. Захист: прапорець loadingMore, перевірка hasMore, не стартувати другий fetch, поки перший не закінчився. У цій статті — mock; з RTK Query / TanStack Query захист ляже на шар даних.

Демо: «ще 10», коли внизу

TSXPaginationUI.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Стан помилки списку

Порожній масив після успішного відповіді «у вас 0 поїздок» і порожній екран після мережевої помилки — різні речі. Не зводьте обидва до одного ListEmptyComponent без розрізнення.

Типова модель UI (ще до RTK Query):

СтанЩо на екрані
loading (перший раз)скелетон / спінер по центру, не empty
success + data.length === 0ListEmptyComponent («Створіть першу поїздку»)
success + є даніFlatList з рядками
errorбанер / повний error-state з «Спробувати знову» (часто замість списку або над ним)
refreshingсписок лишається, зверху RefreshControl
Поки в курсі mock-дані в пам’яті, error-state можна зімітувати кнопкою «Зіпсувати завантаження». У проді помилка прийде з шару мережі — але розмітка екрана лишиться тією ж ідеєю.

SectionList — секції з заголовками

SectionList — віртуалізований список, де дані розбиті на секції (sections). У кожної секції є заголовок (title / renderSectionHeader) і свій масив data.

Аналогія: контакти в телефоні, згруповані за літерою; меню ресторану за категоріями; каталог книг за жанром.

Форма даних:

type Section = {
  title: string;
  data: Book[];
};

const sections: Section[] = [
  { title: 'Фантастика', data: [/* … */] },
  { title: 'Детектив', data: [/* … */] },
];
<SectionList
  sections={sections}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <BookRow book={item} />}
  renderSectionHeader={({ section: { title } }) => (
    <Text style={styles.sectionHeader}>{title}</Text>
  )}
  stickySectionHeadersEnabled
/>
stickySectionHeadersEnabled за замовчуванням true: заголовок секції «липне» зверху, доки його не витіснить наступний. Звичний патерн системних списків Apple.

Демо: секції

TSXSectionListDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Продуктивність рядка: memo, стабільні колбеки

Віртуалізація не скасовує дорогий рядок. Якщо кожен TripCard тягне величезне зображення без розмірів, робить важкі обчислення в render і слухає зайвий контекст — список усе одно гальмуватиме.

Практичні звички (достатньо «на зараз»; глибокий profiling — окрема стаття):

  1. React.memo на компоненті рядка — не перемальовувати, якщо trip і onPress ті самі за посиланням.
  2. useCallback для renderItem / onPress — щоб memo мав шанс спрацювати.
  3. Не створювати нову функцію-обгортку в JSX без потреби в кожному рядку, якщо можна передати item і стабільний handler.
  4. Картинки: фіксована висота обкладинки, розумний розмір URI (w=800), пізніше — кеш / expo-image.
  5. getItemLayout (опційно), якщо усі рядки однакової висоти — список швидше рахує offsets без вимірювання. Для карток різної висоти часто не використовують.
const renderItem = useCallback(
  ({ item }: { item: Trip }) => (
    <TripCard trip={item} onPress={handleOpenTrip} />
  ),
  [handleOpenTrip],
);
extraData. Виділення рядка (selectedId) живе позаitem. Без extraData={selectedId} FlatList може вирішити, що data не змінився, і не оновити підсвітку. Передавайте все «зовнішнє», від чого залежить вигляд рядка.

FlashList — коли FlatList уже «не тягне»

FlashList (@shopify/flash-list) — список від Shopify з більш агресивним recycling рядків і зазвичай кращою продуктивністю на довгих/складних списках, ніж у вбудованого FlatList. API навмисно близький до FlatList: часто міграція — це зміна імпорту й кілька props.

Навіщо він з’явився

FlatList достатній для багатьох екранів. Але на стрічках з картинками, змінною висотою, складними картками з’являються «порожні дірки» під час швидкого скролу, jank, зайві mount/unmount. FlashList перевикористовує view-рядків (ідея близька до Android RecyclerView).

Встановлення (Expo)

npx expo install @shopify/flash-list
# або
npm install @shopify/flash-list

Сумісний з Expo Go (окремий native config plugin для базового сценарію зазвичай не потрібен).

Міграція з FlatList (ідея)

// було
import { FlatList } from 'react-native';
<FlatList data={} renderItem={} keyExtractor={} />

// стало
import { FlashList } from '@shopify/flash-list';
<FlashList data={} renderItem={} keyExtractor={} />
Часто вимагав estimatedItemSize — приблизну висоту (або ширину) рядка в dp. Без оцінки layout «стрибав». Брали середню висоту картки (наприклад, 220).

Важливі відмінності від FlatList

ТемаЩо знати
RecyclingРядок може одержати нові props без повного unmount — локальний useState у рядку «переїде» на інший item, якщо не синхронізувати зі item.id
key у дереві itemНе розставляйте зайві key всередині item tree «по-старому» — суперечить recycle
Nested listsГоризонтальний список у header — ок; важкі вкладені вертикальні списки все одно болючі для UX
Прев’ю на сайті курсуУ ::react-native-preview пакета немає — перевірка на пристрої / симуляторі
Стан усередині item-компонента. Якщо в рядку const [open, setOpen] = useState(false) і користувач розкрив акордеон, після recycle цей же компонент-інстанс може показати іншийitem уже з open === true. Або ключуйте стан словником id → state зверху, або скидайте стан у useEffect від item.id.
У Nomad на домашньому екрані стрічка поїздок — FlashList. Горизонтальні «останні місця» — FlatList horizontal (короткий список, API з коробки). Обидва підходи законні; головне — не мапити 200 карток у ScrollView.

Антипатерни

АнтипатернЧому поганоЩо замість
ScrollView + data.map на 100+Усе в пам’яті одразуFlatList / FlashList
keyExtractor = indexПлутанина ідентичностіitem.id
Новий renderItem={() => …} без memo-стратегіїЗайві ререндериuseCallback + memo на рядку
Список без flex: 1Немає bounded heightЛанцюг flex від Screen
Empty і error — один екранКористувач не розуміє причинуОкремі стани
onEndReached без loadingMoreПодвійні запитиПрапорець / захист у data-шарі
Ігнорувати recycle у FlashList«Липкий» UI-стан рядкаСтан від item.id

Міні-проєкт: «Каталог книг»

Що має вийти

Окремий маленький Expo-екран (можна тимчасовий проєкт npx create-expo-app book-catalog або окремий файл у пісочниці):

  1. 200+ mock-книг.
  2. Групування в секції за жанром (SectionList).
  3. Pull-to-refresh (перемішує / оновлює «рейтинг» mock).
  4. Порожній стан (кнопка «Очистити полицю» / «Заповнити знову»).
  5. (Опційно) footer пагінації або одразу всі 200 у sections — на вибір; головне — віртуалізація, не ScrollView.

Модель даних

type Book = {
  id: string;
  title: string;
  author: string;
  genre: string;
  year: number;
};

type BookSection = {
  title: string;
  data: Book[];
};

Кроки

1. Згенерувати mock

Масив жанрів: ['Фантастика', 'Детектив', 'Подорожі', 'Історія', 'Поезія'].
Цикл на 220 елементів: id, назва Книга ${i}, автор, жанр по модулю, рік.

2. Згрупувати в sections

reduce / Map за genre → масив { title, data }, відсортувати title.

3. SectionList

renderItem — рядок (назва, автор, рік).
renderSectionHeader — жанр.
stickySectionHeadersEnabled — увімкнути й глянути на iOS/Android.

4. Refresh

RefreshControl: під час refresh — setSeed(s => s+1) і перезібрати mock (інші «рейтинги» в підзаголовку).

5. Empty

Кнопка «Очистити» → sections = []ListEmptyComponent.
«Заповнити» → знову generate.

6. Перевірка

Скрол має бути плавним; у React DevTools / логіці ви не монтуєте 220 важких дерев одночасно як у ScrollView (перевірка «на око»: швидкий fling без багатосекундного freeze на старті).

Повний орієнтир коду (згорнуто)

Код повний — розгорніть, щоб скопіювати в App.tsx пісочниці. У прев’ю нижче — стисліша версія для браузера.

Живе прев’ю (після кроків)

TSXBookCatalog.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Nomad: віртуалізовані поїздки та місця

Навіщо користувачу

Стрічка росте. Потрібна віртуалізація, pull-to-refresh і горизонтальні «останні місця», без втрати теми й sticky CTA з попередніх статей.

Нитка проєкту

Уже є (з попередніх статей — не викидаємо):

  • Тема (08): ThemeProvider, чіпи Система/Світла/Темна в sticky header.
  • Shell (07): header + середня зона + sticky «Нова поїздка».
  • Картки (06): TripCard + mockTrips (було 3; список через ScrollView + map).

Додаємо в цій статті:

  • Залежність @shopify/flash-list; стрічка поїздок — FlashList (не ScrollView + map).
  • Більше mock-поїздок; поле region на Trip.
  • Фіча місць: Place, mockPlaces, PlaceChip, horizontal FlatList у ListHeaderComponent.
  • RefreshControl, ListEmptyComponent; TripCard / PlaceChip у memo.
  • Не прибираємо чіпи теми з 08 — вони лишаються в sticky header.

Повний знімок проєкту після цієї статті

Готовий код 1:1 з Nomad. Найшвидший шлях: git pull у клоні репо. Нижче — весь проєкт на момент цієї статті (усі файли з кодом), не фрагмент «лише нові папки». Клік по файлу в дереві → повний вміст для копіпасту.
cd /path/to/nomad
git pull
npm install
npx expo start

Перевірка

  1. Чіпи теми в шапці залишились і працюють (light/dark/system).
  2. Стрічка поїздок скролиться (12 карток); місця — горизонтально під шапкою (у header списку).
  3. Pull-to-refresh — спінер ~0.9 с.
  4. Sticky «Нова поїздка» внизу на місці.

Коміт

Якщо збирали вручну (не через git pull на вже запушений репо):

cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: virtualized trips and places lists

Material: content/15.react-native/09.lists-and-virtualization.md
EOF
)"
git push

У публічному репо цей коміт уже є — після git pull повторно комітити не потрібно, якщо ви не змінювали код локально.

Результат (прев’ю)

Самодостатнє демо для сайту (core RN). У репо — FlashList + @/… + ThemeProvider (див. дерево вище). Чіпи теми й sticky CTA — як у живому Nomad.

TSXNomadLists.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Практичні завдання

Базовий рівень

  1. Своїми словами пояснити, чим ScrollView + map відрізняється від FlatList.
  2. Зібрати FlatList на 30 рядків з keyExtractor по id.
  3. Додати ListEmptyComponent і кнопку, що очищує data.

Середній рівень

  1. Міні-проєкт «Каталог книг»: 200+ items, SectionList за жанром, pull-to-refresh.
  2. У Nomad тимчасово підмінити FlashList на FlatList і назад — порівняти код (майже drop-in).
  3. Додати mock onEndReached: дописувати по 5 поїздок, поки не стане 30.

Професійний рівень

  1. Error-state: кнопка «Симулювати помилку» ховає список і показує «Спробувати знову».
  2. React.memo + логіка «чому рядок ререндериться» (тимчасовий console.log у TripCard).
  3. Горизонтальний FlashList місць замість FlatList (якщо версія API це зручно підтримує) і нотатка про nested scrolling.

Часті запитання


Що далі

Далі — форми: TextInput, клавіатура, валідація (RHF + Zod), екран створення поїздки. Списки вже вміють показувати результат; форми навчать додавати дані в ці списки.

FlatList · SectionList · VirtualizedList · RefreshControl · FlashList · Nomad

Copyright © 2026