Version2

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

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

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

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

У мобільних застосунках списки є домінуючим патерном представлення інформації: стрічки соціальних мереж, каталоги інтернет-магазинів, списки транзакцій, чати та аудіоплеєри побудовані на базі прокручуваних масивів елементів. У навчальних прикладах часто оперують невеликими масивами з 3–5 об’єктів, де будь-який підхід до рендерингу видається швидким. Проте в реальних умовах мобільний клієнт повинен відображати сотні або тисячі складних сутностей, кожна з яких містить векторну графіку, растрові зображення, стилізовану типографіку та обробники жестів.

Якщо підійти до рендерингу довгого списку наївно — наприклад, використати стандартний контейнер ScrollView та виконати пряму ітерацію array.map() — застосунок миттєво зіткнеться з фундаментальними апаратними обмеженнями мобільного пристрою. Створення сотень нативних представлень (native views) в один момент спричиняє різке зростання споживання оперативної пам'яті (RAM), блокування головного потоку JavaScript та графічного потоку UI, просідання частоти кадрів (jank/frame drops) і, в найгіршому випадку, аварійне завершення процесу операційною системою через Out-Of-Memory (OOM).

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

Цей розділ присвячено фундаментальній концепції віртуалізації інтерфейсу (UI Virtualization), її математичним і архітектурним принципам, внутрішній будові компонентів FlatList та SectionList, механізмам рециклінгу представлень у FlashList, а також патернам управління станами списку (пагінація, pull-to-refresh, обробка помилок та порожніх станів).

Після опрацювання матеріалу ви зможете:

  1. Глибоко розуміти фізичну різницю між нативним рендерингом ScrollView + map та віртуалізованим віконним відображенням.
  2. Проєктувати високоефективні компоненти на базі FlatList, бездоганно налаштовуючи зв'язку data, renderItem, keyExtractor та параметри буферизації.
  3. Реалізовувати повноцінний UX-контракт списку: відображення порожнього стану (ListEmptyComponent), композицію шапки (ListHeaderComponent) та підвалу (ListFooterComponent), а також двонаправлене оновлення за допомогою жесту RefreshControl.
  4. Конструювати надійні механізми нескінченної пагінації (onEndReached), коректно контролюючи порогові значення та запобігаючи стану гонитви (race conditions).
  5. Структурувати складні ієрархічні дані за допомогою SectionList, налаштовуючи поведінку закріплення заголовків (stickySectionHeadersEnabled) з урахуванням специфіки iOS та Android.
  6. Аналізувати та усувати вузькі місця продуктивності, застосовуючи мемоізацію рядків (React.memo), стабільні посилання (useCallback), попереднє обчислення геометрії (getItemLayout) та зовнішній стан (extraData).
  7. Оцінювати доцільність переходу на FlashList, розуміючи концептуальну різницю між циклічним видаленням/монтуванням вузлів та парадигмою рециклінгу комірок (Cell Recycling).
  8. Інтегрувати багаторівневі списки в архітектуру реального проєкту на прикладі стрічки поїздок та горизонтального каталогу місць у застосунку Nomad.
Фундаментальний принцип віртуалізації. Мобільний пристрій не повинен створювати нативні графічні об'єкти для даних, які перебувають поза межами видимого екрана (viewport). Віртуалізований список підтримує в активному дереві рендерингу лише мінімальну підмножину елементів — так зване «вікно перегляду» плюс невеликий буфер попереднього завантаження зверху та знизу. Під час прокручування елементи, що виходять за межі буфера, демонтуються або перевикористовуються, а нові монтуються динамічно. Користувач сприймає стрічку як нескінченну та цілісну, тоді як споживання пам'яті залишається константним: $O(1)$ щодо загальної кількості елементів масиву.
Особливості інтерактивних демонстрацій. Усі приклади в цій статті забезпечені живими прев'ю на базі ::react-native-preview (середовище react-native-web). Стандартні компоненти FlatList, SectionList та RefreshControl функціонують у веб-пісочниці повноцінно. Зверніть увагу: бібліотека FlashList є зовнішнім нативним модулем і відсутня у браузерному iframe; її архітектуру, API та інтеграцію детально розглянуто в теоретичній частині та в кодовій базі повноцінного проєкту Nomad.

Порівняльний аналіз: Web React та React Native

Розробники, які переходять у React Native з екосистеми веб-розробки, часто переносять звичні підходи до побудови списків без урахування специфіки мобільних рушіїв рендерингу. У браузері створення кількох сотень простих DOM-вузлів <li> є відносно дешевою операцією, оскільки сучасні рушії (V8, Blink, Gecko) оптимізовані для швидкого парсингу HTML-дерева. У React Native кожен візуальний елемент — це не просто запис у пам'яті JS-рушія, а реальний нативний віджет операційної системи (UIView в iOS або android.view.ViewGroup в Android) із власними графічними контекстами, пам'яттю під текстури та прорахунком розкладки в рушії Yoga.

Концепція у Web ReactЕквівалент у React NativeАрхітектурна та поведінкова різниця
<ul>{items.map(...)}</ul>ScrollView + mapПовний монолітний рендеринг. Усі нативні вузли та зображення інстанціюються одночасно до першого показу екрана.
Глобальний скрол вікна (window)ScrollViewКонтейнер з фіксованими або розтягнутими межами, що реалізує нативну механіку інерційної прокрутки.
react-window, react-virtualized, @tanstack/virtualFlatList / SectionList / FlashListВіртуалізований рендеринг. Створюються лише елементи, наближені до поточної координати прокручування.
IntersectionObserver (Infinite Scroll)onEndReached у спискахРозрахунок відносного зміщення скролу для завчасного підвантаження порцій даних із сервера.
CSS pull-to-refresh / Touch eventsRefreshControlПряма обгортка над системними віджетами оновлення (UIRefreshControl в iOS та SwipeRefreshLayout в Android).
Типова пастка веброзробника. Конструкція {trips.map(trip => <TripCard key={trip.id} {...trip} />)} всередині ScrollView є допустимою виключно для гарантовано обмежених вибірок (до 5–10 елементів). Якщо передати в таку структуру масив із 100 карток з обкладинками, додаток ініціалізує 100 нативних об'єктів зображень і прорахує повний лейаут всього полотна. На пристроях середнього та бюджетного сегментів це гарантовано призведе до тривалого «зависання» інтерфейсу (TTI / Time to Interactive) та деградації пам'яті.

Що ламається без віртуалізації: анатомія наївного рендерингу

Щоб чітко усвідомити необхідність віртуалізації, розглянемо життєвий цикл екрана, на якому відображається стрічка з 200 карток поїздок через ScrollView + map.

Поетапний аналіз наївного рендерингу

1. Фаза ініціалізації та узгодження (Reconciliation)

React виконує функцію компонента і генерує віртуальне дерево (Virtual DOM) для всіх 200 елементів масиву. Якщо кожна картка містить 10 дочірніх вузлів (View, Text, Image, Pressable), рушій JavaScript створює понад 2000 віртуальних об'єктів. Відбувається масове виділення пам'яті під JS-об'єкти, що створює значний тиск на збирач сміття (Garbage Collector).

2. Серіалізація та міст / JSI виклики

Опис дерева передається через міст (або через прямі C++ біндінги JSI в новій архітектурі) у нативний шар. Створюються 2000 нативних представлень операційної системи. Кожен компонент Image починає паралельне або послідовне завантаження, декодування растрових зображень та розміщення їхніх бітових карт (bitmaps) у відеопам'яті (VRAM).

3. Розрахунок геометрії (Yoga Layout Engine)

Нативний компоновочний рушій Yoga розраховує координати (x, y, width, height) для кожного з 2000 вузлів на всю теоретичну висоту полотна (яка може складати десятки тисяч пікселів), попри те, що на екрані пристрою фізично вміщується лише 2–3 картки.

4. Рендеринг та інерційна прокрутка

Коли користувач торкається екрана і починає скролити, графічний процесор (GPU) змушений оперувати гігантським шаром компонування. Будь-яка зміна стану одного з компонентів призводить до каскадного перерахунку всього масиву. З'являються мікрофризи, пропуски кадрів, а у випадку вичерпання ліміту пам'яті операційна система надсилає сигнал SIGKILL (Out of Memory termination).

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

rectangle "ScrollView + map (Монолітний підхід)" as S {
  rectangle "Картка 1 (видима в viewport)" as C1 #Business
  rectangle "Картка 2 (видима в viewport)" as C2 #Business
  rectangle "Картки 3 … 200\n(Усі 198 вузлів змонтовані в нативному дереві й пам'яті)" as C3 #APPLICATION
}

rectangle "FlatList / FlashList (Віртуалізований підхід)" as F {
  rectangle "Буфер верхнього відсікання (1–2 рядки)" as B1 #TECHNOLOGY
  rectangle "Активне вікно відображення (Viewport)" as V #Business
  rectangle "Буфер нижнього відсікання (1–2 рядки)" as B2 #TECHNOLOGY
  rectangle "Незмонтовані дані (200 елементів перебувають\nвиключно у вихідному JS-масиві)" as Rest #STRATEGY
}

note right of S
  Недоліки:
  * O(N) нативних View у пам'яті
  * Високий час TTI
  * Ризик OOM Crash
end note

note right of F
  Переваги:
  * O(1) нативних View у пам'яті
  * Миттєвий холодний старт
  * Стабільні 60/120 FPS
end note

@enduml

Фундаментальні концепції віртуалізації списків

Віртуалізація списків (List Virtualization / Windowing) — це алгоритмічний підхід до рендерингу великих або потенційно нескінченних наборів даних, за якого кількість створених візуальних елементів інтерфейсу пропорційна розміру видимої області екрана, а не загальній довжині вхідного масиву даних.

У React Native базовим вбудованим інструментом віртуалізації є компонент FlatList, побудований поверх низькорівневого абстрактного компонента VirtualizedList.

Для розуміння математики віртуалізованого списку необхідно виділити ключові сутності:

Вхідний набір даних (Data Source)
T[]
Вихідний масив елементів у пам'яті JavaScript. Сам по собі масив із навіть 100 000 простих JavaScript-об'єктів займає лише кілька мегабайтів RAM і не створює навантаження на графічну підсистему, доки ці об'єкти не перетворено на нативні візуальні вузли.
Вікно перегляду (Viewport / Render Window)
Геометрична область
Діапазон координат по осі прокручування, який відповідає фізичним розмірам компонента списку на екрані плюс заданий коефіцієнт буферизації. Список відстежує поточне зміщення скролу (contentOffset.y) і динамічно обчислює індекси елементів [startIndex, endIndex], які потрапляють у цю область.
Буфер відсікання (Overscan / Window Size)
Множник розміру
Кількість додаткових екранів контенту, які монтуються вище та нижче фізичного вікна перегляду. Буфер необхідний для того, щоб під час швидкого скролу користувач не бачив білих порожнеч (blank areas) до моменту, поки React встигне змонтувати нові рядки.
Демонтування проти рециклінгу (Unmount vs Recycle)
Архітектурна модель
Два фундаментальні підходи до звільнення ресурсів:
  1. Unmount (FlatList): елементи, що виходять за межі буфера, повністю видаляються з дерева React і нативного дерева, звільняючи пам'ять. При повторному скролі вони інстанціюються наново.
  2. Recycle (FlashList): фізичні нативні представлення не знищуються, а зберігаються в пулі (pool) і повторно заповнюються новими даними (props), що мінімізує алокацію пам'яті та навантаження на GC.
Ключ ідентичності (Key Extraction)
(item: T, index: number) => string
Механізм визначення стабільної бізнес-ідентичності елемента. Дозволяє алгоритму Reconciliation точно визначати, чи елемент змістив свою позицію, чи був доданий/видалений, чи змінив внутрішній стан.

FlatList — архітектура та анатомія базового списку

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

Мінімальний структурний шаблон виглядає так:

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

interface ArticleItem {
  id: string;
  title: string;
}

const ARTICLES: ArticleItem[] = [
  { id: 'art-1', title: 'Архітектура мобільних застосунків' },
  { id: 'art-2', title: 'Оптимізація пам’яті в React Native' },
  { id: 'art-3', title: 'Патерни віртуалізації списків' },
];

export function MinimalArticleList() {
  return (
    <FlatList
      data={ARTICLES}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View style={styles.card}>
          <Text style={styles.title}>{item.title}</Text>
        </View>
      )}
    />
  );
}

const styles = StyleSheet.create({
  card: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#ccc' },
  title: { fontSize: 16, fontWeight: '500' },
});

Повний огляд параметрів (Props) FlatList

Для побудови надійного і продуктивного інтерфейсу необхідно володіти повною картою параметрів FlatList:

data
readonly ItemT[] | null | undefined
Основне джерело даних. FlatList здійснює неглибоке порівняння (shallow comparison) посилання на масив data. Зміна вмісту масиву без зміни посилання не викличе перерендер списку.
renderItem
(info: { item: ItemT, index: number, separators: ... }) => ReactElement | null
Функція-фабрика, що повертає візуальний компонент для конкретного елемента. Викликається тільки для елементів, які потрапляють у вікно віртуалізації. Критично важливо: функція має бути стабільною (огорнута в useCallback або винесена за межі рендера батьківського компонента), щоб уникнути непотрібної інвалідації всіх видимих рядків.
keyExtractor
(item: ItemT, index: number) => string
Функція отримання унікального рядкового ідентифікатора. За замовчуванням FlatList перевіряє наявність поля item.key або item.id. Явне визначення keyExtractor є обов'язковим стандартом, якщо структура ваших даних відрізняється від дефолтної.
ListHeaderComponent / ListFooterComponent
React.ComponentType<any> | React.ReactElement | null
Компоненти шапки та підвалу, які розміщуються всередині загального прокручуваного простору. Вони скроляться разом з елементами списку, на відміну від фіксованих зовнішніх блоків.
ListEmptyComponent
React.ComponentType<any> | React.ReactElement | null
Компонент, що автоматично монтується, коли масив data порожній (data.length === 0). Дозволяє декларативно описувати стан відсутності даних.
ItemSeparatorComponent
React.ComponentType<any> | null
Рендериться між сусідніми елементами списку. Не відображається перед першим і після останнього елемента, що вирішує класичну проблему зайвих margin або ліній розділення.
contentContainerStyle
StyleProp<ViewStyle>
Стильовий об'єкт, що застосовується до внутрішнього контейнера скролу (нативного полотна, на якому розташовуються всі елементи). Використовується для завдання внутрішніх відступів (padding) списку та властивості flexGrow: 1.
style
StyleProp<ViewStyle>
Стиль зовнішнього контейнера списку (рамки вікна перегляду). Зазвичай містить flex: 1 для обмеження висоти списку розмірами батьківського екрана.
extraData
any
Маркер інвалідації кешу віртуалізації. Якщо рендеринг рядків залежить від зовнішнього стану (наприклад, стан вибраного ID, локалізація, глобальна тема), цей стан необхідно передати в extraData. Оскільки renderItem оптимізовано через PureComponent/memo, без зміни extraData список проігнорує оновлення зовнішніх залежностей.
refreshControl / refreshing + onRefresh
React.ReactElement<RefreshControlProps>
Механізм підтримки жесту Pull-to-Refresh. Дозволяє підключити системний компонент <RefreshControl /> для асинхронного перезавантаження вибірки.
onEndReached / onEndReachedThreshold
() => void / number
Колбек та числовий коефіцієнт (від 0 до 1) для реалізації пагінації. Значення 0.5 означає, що функція onEndReached спрацює, коли нижня межа списку буде на відстані половини висоти вікна перегляду від кінця контенту.
horizontal
boolean
Перемикає орієнтацію списку з вертикальної на горизонтальну. При цьому головна вісь прокручування стає горизонтальною, а розділювачі та шапки адаптують своє розташування.
initialNumToRender
number (дефолт: 10)
Кількість елементів, що монтуються в початковому пакеті рендерингу. Безпосередньо впливає на час до першого відмалювання екрана (TTI / First Contentful Paint). Зменшення цього числа прискорює відкриття екрана, але занизьке значення може призвести до миготіння порожнього простору на високих екранах.
maxToRenderPerBatch
number (дефолт: 10)
Максимальна кількість елементів, які список додає в дерево за один інкрементний крок під час прокручування. Дозволяє балансувати між навантаженням на процесор і плавністю появи нових рядків.
updateCellsBatchingPeriod
number (дефолт: 50 мс)
Часовий інтервал у мілісекундах між пакетами інкрементного рендерингу.
removeClippedSubviews
boolean (дефолт: true на Android)
Нативна оптимізація пам'яті: від'єднує позаекранні нативні представлення (UIView/View) від віконної ієрархії на рівні ОС. Суттєво знижує споживання VRAM, але на iOS іноді може викликати тимчасові артефакти зникнення складного вкладеного контенту або текстових полів.
numColumns
number (дефолт: 1)
Кількість колонок для побудови сітки (Grid Layout). Перетворює список на матрицю.
columnWrapperStyle
StyleProp<ViewStyle>
Стиль для обгортки рядка колонок, коли numColumns > 1 (наприклад, для рівномірного розподілу карток через justifyContent: 'space-between').
inverted
boolean (дефолт: false)
Інвертує напрямок осі прокручування. Список починається знизу (нульовий елемент внизу), скрол іде вгору, а подія onEndReached генерується при наближенні до верхнього краю. Базовий патерн для екранів чатів та стрічок повідомлень.
getItemLayout
(data, index) => { length: number, offset: number, index: number }
Опціональна, але вкрай потужна оптимізація. Якщо всі елементи списку мають фіксовану та наперед відому висоту, ця функція дозволяє списку миттєво обчислювати позицію будь-якого елемента без проходження фази нативного динамічного вимірювання (Layout Measurement).

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

TSXFlatListBasic.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Правило обмеженої висоти (Bounded Height Constraint). Як і базовий контейнер ScrollView, віртуалізований список вимагає жорстко обмеженої геометрії батьківського контейнера вздовж осі прокручування (зазвичай через flex: 1 у всьому ланцюжку: ScreenView-обгорткаFlatList). Якщо список помістити в батьківський блок із необмеженою висотою (наприклад, усередину іншого вертикального ScrollView або в контейнер з автоматичною висотою height: 'auto'), FlatList не зможе визначити межі фізичного вікна перегляду. У результаті він спробує розгорнути все своє полотно на повну висоту, змонтувавши абсолютно всі рядки одночасно, що повністю нівелює ефект віртуалізації.

keyExtractor: алгоритм узгодження та стабільність ідентичності

У React Native механізм keyExtractor відіграє вирішальну роль у функціонуванні алгоритму узгодження (Reconciliation Algorithm). Під час кожної зміни структури або порядку масиву даних React порівнює попереднє дерево віртуальних вузлів із новим. Ключ (key) є єдиним критерієм, за яким рушій визначає, чи конкретний елемент зберігся, змістився на нову позицію, був доданий або видалений.

// ❌ Категорично заборонено для динамічних або змінних списків:
keyExtractor={(_, index) => String(index)}

// ✅ Інженерний стандарт: унікальний та незмінний бізнес-ідентифікатор:
keyExtractor={(item) => item.id}

Чому індекс масиву руйнує роботу списку?

Використання порядкового індексу (index) як ключа є критичним антипатерном, який призводить до трьох категорій дефектів:

  1. Витік та зміщення внутрішнього стану (Component State Leakage). Якщо компонент рядка містить локальний стан (useState, useRef, стан розгорнутого акордеона або текст у полі вводу), цей стан прив'язується React до ключа, а не до бізнес-об'єкта. Якщо видалити перший елемент масиву (індекс 0), елемент з колишнім індексом 1 тепер отримує індекс 0. React вважає, що компонент залишився тим самим, і «успадковує» внутрішній стан видаленого елемента для нових даних.
  2. Деградація продуктивності через масовий ререндер. При додаванні нового елемента на початок списку індекси всіх наступних 100 елементів змінюються ($0 \to 1$, $1 \to 2$, ...). React змушений повністю перемалювати все вікно віртуалізації, замість того щоб виконати одну операцію вставки нативного представлення.
  3. Руйнування рециклінгу та нативних анімацій. У бібліотеках з рециклінгом (FlashList) або при використанні LayoutAnimation невірні ключі призводять до візуальних артефактів: мерехтіння зображень, некоректного прикріплення асинхронно завантажених текстур та стрибків скролу.
Заборона ручного встановлення key у renderItem. Ніколи не передавайте проп key={item.id} на кореневий елемент, який повертається з функції renderItem. FlatList автоматично огортає кожен рядок у внутрішній контейнер CellRenderer і самостійно управляє ключами на основі результату функції keyExtractor. Ручне дублювання ключів створює конфлікти в дереві рендерингу та ламає механізми перевірки оновлень.

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

Професійно спроєктований список повинен коректно обробляти всі можливі стани інтерфейсу: початкове завантаження, відображення даних, стан відсутності результатів та структуроване обрамлення.

Архітектурна композиція списку

  1. ListHeaderComponent та ListFooterComponent: інтегруються безпосередньо в координату прокручування. Шапка з'являється над першим елементом, підвал — під останнім. Це ідеальне місце для пошукових панелей, банерів, фільтрів або індикаторів завантаження наступної сторінки.
  2. ItemSeparatorComponent: вирішує класичну проблему відступів між плитками. На відміну від статичного marginVertical на картці, розділювач не рендериться перед нульовим елементом і після фінального, гарантуючи ідеальну посадку контенту в межах контейнера.
  3. ListEmptyComponent: монтується тоді й лише тоді, коли data.length === 0 (і при цьому data не є null/undefined). Порожній стан — це не екранний збій, а важлива частина комунікації з користувачем (Empty State UX), що пояснює причину відсутності записів і пропонує дію (кнопка «Оновити», «Створити поїздку» тощо).
TSXListEmpty.tsx
Android
9:41

Loading…

react-native-web · not a real device

Центрування порожнього стану через flexGrow: 1. Типова проблема верстки: при відсутності даних ListEmptyComponent «прилипає» до верхнього краю екрана, оскільки висота внутрішнього полотна списку дорівнює нулю. Щоб розмістити порожній стан по центру екрана, задайте contentContainerStyle={{ flexGrow: 1 }} (динамічно або постійно). На відміну від flex: 1, який жорстко фіксує розмір і ламає прокручування при появі довгих даних, властивість flexGrow: 1 дозволяє контейнеру займати щонайменше 100% висоти батька, якщо контенту мало, але вільно розширюватися далі, коли з'являються записи.

Мультиколонкові сітки (Grids): numColumns та динамічна перебудова

У React Native немає вбудованого аналога CSS Grid. Проте FlatList надає нативну підтримку сіткових розкладок через параметр numColumns.

Коли numColumns > 1, список групує елементи в горизонтальні рядки і застосовує до кожного такого рядка додатковий контейнер-обгортку:

<FlatList
  data={photos}
  keyExtractor={(item) => item.id}
  numColumns={2}
  columnWrapperStyle={styles.rowWrapper}
  renderItem={({ item }) => <PhotoCard photo={item} />}
/>
const styles = StyleSheet.create({
  rowWrapper: {
    justifyContent: 'space-between',
    gap: 12,
    marginBottom: 12,
  },
});
Динамічна зміна кількості колонок. Рушій FlatList не підтримує зміну numColumns «на льоту» без скидання внутрішнього стану рендерингу (виникає виняток: «Changing numColumns on the fly is not supported without changing the key prop»). Якщо ваш інтерфейс дозволяє перемикати вигляд між 1 та 2 колонками (наприклад, перемикач «Список / Сітка»), обов'язково прив'яжіть numColumns до ключа самого списку: <FlatList key={grid-${numColumns}} numColumns={numColumns} ... />.

Імперативне управління скролом: робота з ref

У реальних мобільних сценаріях часто виникає потреба програмного переміщення до певної точки списку: кнопка «Повернутися вгору» (Scroll to top), перехід до непрочитаного повідомлення в чаті або синхронізація з календарем.

Для цього використовується ref-посилання на екземпляр FlatList:

import { useRef } from 'react';
import { FlatList } from 'react-native';

export function TripListScreen() {
  const listRef = useRef<FlatList<Trip>>(null);

  const scrollToTop = () => {
    listRef.current?.scrollToOffset({ offset: 0, animated: true });
  };

  const scrollToTripIndex = (index: number) => {
    listRef.current?.scrollToIndex({
      index,
      animated: true,
      viewPosition: 0.5, // 0 = зверху, 0.5 = по центру, 1 = знизу
    });
  };

  return <FlatList ref={listRef} data={trips} ... />;
}

Методи імперативного API

МетодСигнатураПризначення та особливості
scrollToOffset{ offset: number, animated?: boolean }Прокручує полотно списку до точної координати у пікселях/dp вздовж осі скролу.
scrollToIndex{ index: number, animated?: boolean, viewPosition?: number, viewOffset?: number }Переміщує вікно перегляду до елемента за його індексом у масиві data.
scrollToEnd{ animated?: boolean }Прокручує список до самого кінця (часто використовується в чатах при надсиланні нового повідомлення).
scrollToItem{ item: ItemT, animated?: boolean, viewPosition?: number }Прокручує список до об'єкта, знаходячи його через пряме порівняння посилань у data.
Пастка scrollToIndex без getItemLayout. Якщо викликати scrollToIndex({ index: 85 }) для елемента, який ще не був змонтований і знаходиться далеко за межами поточного вікна віртуалізації, FlatList не знає його точної фізичної координати y. Це призведе до падіння з помилкою: «scrollToIndex out of range».
Рішення: для гарантовано надійного переходу до довільних індексів обов'язково визначайте функцію getItemLayout, яка повідомляє списку точні координати без попереднього вимірювання.

Pull-to-refresh: архітектура нативного оновлення

Pull-to-refresh (потягни для оновлення) — фундаментальний мобільний патерн взаємодії, за якого користувач здійснює вертикальний жест протягування списку вниз від нульової координати скролу, ініціюючи повторне завантаження або синхронізацію вибірки даних.

На відміну від веб-рішень на основі JavaScript-анімацій, у React Native цей жест делегується системним віджетам операційної системи:

  • iOS: компонент UIRefreshControl, інтегрований у нативний UIScrollView, з автентичною фізикою пружини (spring physics) та фірмовим індикатором активності.
  • Android: системний контейнер SwipeRefreshLayout із Material Design циркулярним індикатором прогресу, що плаває поверх списку.

Життєвий цикл та UI-контракт RefreshControl

Компонент <RefreshControl /> передається у властивість refreshControl будь-якого вертикального списку (FlatList, SectionList, ScrollView). Робота з ним базується на суворому двосторонньому контракті станів:

refreshing
boolean
Булевий стан, що сигналізує про активну фазу синхронізації. Коли refreshing === true, системний спінер залишається видимим та обертається, навіть якщо користувач уже відпустив палець від екрана.
onRefresh
() => void | Promise<void>
Функція зворотного виклику (callback), яка спрацьовує, коли жест подолав порогову відстань спрацьовування. У цій функції встановлюється прапорець refreshing = true та ініціюється мережевий запит. Після завершення операції (успіх або помилка) прапорець обов'язково скидається у false.
tintColor (iOS) / colors (Android)
string / string[]
Параметри стилізації індикатора під брендову палітру застосунку. На iOS колір спінера задається через рядок tintColor, на Android — масивом шістнадцяткових кодів colors={[primaryColor]}, що визначає кольорову послідовність дуги індикатора.
Збереження контексту при оновленні. Під час виконання pull-to-refresh список не повинен демонтувати вже наявні рядки чи замінювати їх на повноцінний скелетон по центру екрана. Патерн вимагає збереження поточного стану (Stale Data), поки у фоновому режимі надходять свіжі дані (Fresh Data).

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

TSXPullRefresh.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

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

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


Пагінація UI: нескінченний скрол та onEndReached

Пагінація (Pagination / Infinite Scrolling) — стратегія поетапного порційного завантаження даних, яка мінімізує початковий мережевий трафік і час очікування відповіді (TTFB / Time to First Byte). Замість передачі масиву з 10 000 об'єктів сервер повертає дані сторінками (наприклад, по 20 записів), а клієнтський застосунок підвантажує наступний блок у момент, коли користувач наближається до кінця списку.

Математична модель розрахунку порогу

Компонент FlatList безперервно відстежує метрики скролу:

  • $H_{\text{content}}$ — повна висота внутрішнього вмісту (contentSize.height);
  • $H_{\text{viewport}}$ — фізична висота вікна перегляду списку (layoutMeasurement.height);
  • $Y_{\text{offset}}$ — поточне вертикальне зміщення (contentOffset.y).

Подія onEndReached генерується тоді, коли відстань від нижньої межі видимого вікна до кінця вмісту стає меншою або рівною добутку висоти вікна на пороговий коефіцієнт onEndReachedThreshold:

$$(H_{\text{content}} - (Y_{\text{offset}} + H_{\text{viewport}})) \le H_{\text{viewport}} \times \text{onEndReachedThreshold}$$

Наприклад, при onEndReachedThreshold={0.5} подія спрацює, коли користувачеві залишиться прокрутити відстань, що дорівнює половині висоти видимого екрана.

<FlatList
  data={items}
  renderItem={renderRow}
  keyExtractor={(item) => item.id}
  onEndReached={handleLoadMore}
  onEndReachedThreshold={0.3}
  ListFooterComponent={renderFooter}
/>

Запобігання стану гонитви (Race Conditions) та хибним спрацьовуванням

Без належного архітектурного контролю обробник onEndReached стає джерелом критичних багів:

  1. Хибний старт при ініціалізації. Якщо початковий масив містить лише 2–3 елементи, висота контенту $H_{\text{content}}$ може бути меншою за висоту екрана $H_{\text{viewport}}$. У цьому випадку умова спрацювання виконується автоматично під час першого монтування списку.
  2. Паралельні дублюючі запити (Duplicate Triggers). Під час швидкого інерційного скролу подія onEndReached може викликатися багаторазово за короткий проміжок часу. Якщо перший асинхронний запит ще перебуває в процесі виконання, запуск другого запиту призведе до дублювання сторінок або розсинхронізації курсорів пагінації.
Інженерний захист пагінації. Завжди блокуйте повторний виклик завантаження за допомогою комбінації перевірок: if (isLoadingMore || !hasMore) return;
Стан isLoadingMore встановлюється в true перед запитом і скидається в finally-блоці, а hasMore визначається відповіддю сервера (наявність наступної сторінки або досягнення ліміту).

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

TSXPaginationUI.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Відстеження видимості елементів: viewabilityConfig та аналітика показів

У багатьох мобільних застосунках недостатньо просто відрендерити список — необхідно точно знати, які саме елементи користувач фактично бачить на екрані в конкретний момент часу.

Типові сценарії використання:

  1. Автоматичне відтворення медіа: запуск відеоролика, коли картка з'являється у видимій області на 60%+, та пауза при виході за межі екрана (патерн стрічок Instagram/TikTok).
  2. Маркетингова аналітика показів (Ad Impressions): фіксація факту перегляду рекламного банера за стандартом IAB (наприклад, елемент видно на 50%+ протягом щонайменше 1000 мс).
  3. Оновлення навігаційних індикаторів: підсвічування активного розділу або дати при прокручуванні стрічки.

FlatList реалізує це через пару пропсів: конфігуратор viewabilityConfig та обробник подій onViewableItemsChanged:

import { useRef } from 'react';
import { FlatList, ViewToken } from 'react-native';

export function MonitoredTripList() {
  // 1. Конфігурація правил видимості
  const viewabilityConfig = useRef({
    itemVisiblePercentThreshold: 60, // Елемент вважається видимим, якщо 60% його площі у viewport
    minimumViewTime: 300, // Час утримання в мілісекундах для зарахування показу
  }).current;

  // 2. Колбек фіксації зміни видимості
  const onViewableItemsChanged = useRef(
    ({ viewableItems, changed }: { viewableItems: ViewToken[]; changed: ViewToken[] }) => {
      viewableItems.forEach((token) => {
        if (token.isViewable) {
          console.log(`Елемент у фокусі користувача: ID ${token.item.id}, індекс ${token.index}`);
        }
      });
    },
  ).current;

  return (
    <FlatList
      data={trips}
      renderItem={renderTrip}
      viewabilityConfig={viewabilityConfig}
      onViewableItemsChanged={onViewableItemsChanged}
    />
  );
}
Вимога незмінності посилання viewabilityConfig. Рушій React Native вимагає, щоб об'єкт viewabilityConfig та функція onViewableItemsChanged були абсолютно стабільними за посиланням протягом усього життєвого циклу компонента. Зміна цих параметрів під час роботи викличе фатальну помилку: «Changing onViewableItemsChanged on the fly is not supported». Завжди фіксуйте їх через useRef(...).current.

Управління станами списку: скінченний автомат відображення

Поширеною архітектурною помилкою початківців є зведення всіх нетипових станів інтерфейсу до єдиного прапорця isLoading або використання ListEmptyComponent як універсального контейнера для помилок.

Надійний інтерфейс списку функціонує як скінченний автомат станів (State Machine), що гарантує відсутність взаємовиключних або амбівалентних відображень:

Стан автоматаУмови виникненняВізуальне представлення в UIАрхітектурна поведінка
InitialLoadingПерший запуск екрана, дані в кеші відсутні.Скелетонна розмітка (Skeleton Views) або центральний ActivityIndicator.Список FlatList не монтується; ListEmptyComponent заблокований, щоб не спалахувати перед завантаженням.
SuccessWithDataДані успішно отримані, data.length > 0.Повноцінний віртуалізований FlatList із рядками.Працює віртуалізація, доступні жести та скрол.
SuccessEmptyУспішна відповідь сервера (HTTP 200), але масив порожній (data.length === 0).ListEmptyComponent із роз'ясненням та CTA-кнопкою (наприклад, «Створити першу поїздку»).Полотно розтягується через contentContainerStyle={{ flexGrow: 1 }}.
ErrorЗбій мережі (Network Failure), помилка сервера (5xx/4xx) або парсингу.Повноекранний стан помилки з ілюстрацією та кнопкою повторної спроби (Retry).Замінює список або відображається поверх нього у вигляді плаваючого банера.
RefreshingКористувач виконав жест pull-to-refresh під час наявності даних.Поточні дані залишаються на екрані, зверху обертається індикатор RefreshControl.Оптимістичне збереження попереднього стану (Stale-While-Revalidate).
LoadingMoreВиконується довантаження наступної сторінки при скролі вниз.Список залишається інтерактивним, у ListFooterComponent монтується міні-спінер.Блокується повторний запуск onEndReached до отримання відповіді.
Декларативне розділення помилки та порожнечі. Ніколи не підміняйте масив даних порожнім масивом [] у випадку помилки мережі. Порожній список означає: «Ми успішно перевірили базу даних, і там дійсно 0 записів». Помилка означає: «Ми не змогли зв'язатися з сервером, тому не знаємо, скільки там записів».

SectionList — ієрархічні та груповані структури даних

У практиці розробки мобільних застосунків дані часто мають природну двовимірну ієрархію: телефонна книга з контактами за літерами алфавіту, розклад подій за днями тижня, фінансові транзакції за місяцями або каталог книг за жанрами.

Для таких сценаріїв React Native надає спеціалізований віртуалізований компонент SectionList.

Структура даних та типізація секцій

На відміну від FlatList, де проп data приймає одновимірний масив T[], SectionList оперує пропом sections, що приймає масив секційних об'єктів формату SectionListData<ItemT, SectionT>[]:

interface Book {
  id: string;
  title: string;
  author: string;
}

interface BookSection {
  title: string;
  data: Book[];
}

const CATALOG_SECTIONS: BookSection[] = [
  {
    title: 'Фантастика',
    data: [
      { id: 'b-1', title: 'Дюна', author: 'Френк Герберт' },
      { id: 'b-2', title: 'Нейромант', author: 'Вільям Ґібсон' },
    ],
  },
  {
    title: 'Детективи',
    data: [
      { id: 'b-3', title: 'Ім’я троянди', author: 'Умберто Еко' },
    ],
  },
];

Анатомія рендерингу SectionList

SectionList використовує роздільні фабрики рендерингу для елементів та заголовків:

  • renderItem: відповідає за відображення одного елемента item з внутрішнього масиву section.data.
  • renderSectionHeader: відповідає за відображення заголовка секції (section.title).
  • renderSectionFooter (опціонально): дозволяє виводити підсумкову інформацію секції (наприклад, сумарний баланс за місяць).
<SectionList
  sections={CATALOG_SECTIONS}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => <BookRow book={item} />}
  renderSectionHeader={({ section: { title } }) => (
    <View style={styles.header}>
      <Text style={styles.headerTitle}>{title}</Text>
    </View>
  )}
  stickySectionHeadersEnabled
/>

Механіка прилипання заголовків (Sticky Headers)

Властивість stickySectionHeadersEnabled визначає поведінку заголовків під час прокручування:

За замовчуванням властивість має значення true. Заголовок поточної секції «прилипає» до верхньої межі вікна перегляду списку і залишається нерухомим, доки знизу не підійде заголовок наступної секції, який плавно виштовхує попередній за межі екрана. Це автентична поведінка нативних таблиць iOS (UITableViewStylePlain).

::

Демо: секції

TSXSectionListDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Продуктивність списків та оптимізація рендерингу рядка

Віртуалізація списку вирішує проблему кількості одночасно змонтованих елементів, але не нівелює обчислювальної складності рендерингу окремого рядка.

У мобільній розробці інтерфейс має оновлюватися із частотою 60 кадрів/с (бюджет часу на один кадр: 16.6 мс) або 120 кадрів/с на екранах ProMotion / 120Hz (бюджет: 8.3 мс). Будь-яка затримка в потоці JavaScript або нативному графічному потоці призводить до випадання кадрів (Dropped Frames / UI Jank). Якщо функція renderItem містить важку логіку, немемоізовані об'єкти чи неефективну роботу з графікою, швидкий скрол супроводжуватиметься мікрозависаннями та білими спалахами.

Фундаментальні правила оптимізації комірки

1. Ізоляція та мемоізація компонента рядка (React.memo)

Компонент рядка слід виносити в окрему функцію та загортати в React.memo. Мемоізований компонент перемальовується лише тоді, коли змінюються його безпосередні props (наприклад, посилання на об'єкт trip або колбек onPress), запобігаючи масовому ререндеру всього вікна віртуалізації при оновленні батьківського екрана.

2. Стабільність функціональних посилань (useCallback)

Функції renderItem, keyExtractor та обробники натискань обов'язково огортаються в useCallback або визначаються поза тілом функціонального компонента екрана. Якщо передавати в renderItem анонімну стрілочну функцію renderItem={({ item }) => <TripCard ... />}директивно в JSX, на кожному рендері батьківського екрана створюватиметься нове посилання, що повністю анулює оптимізацію React.memo.

3. Оптимізація графічних шарів та растрових зображень

Зображення є найбільш ресурсомісткими об'єктами списку. Необхідно жорстко задавати фіксовані розміри контейнера картинки (width, height), використовувати попереднє масштабування на стороні CDN/сервера (?w=800&q=80) та уникати важких динамічних ефектів (комбінації великих радіусів згладжування borderRadius разом з overflow: 'hidden' та нативними тінями elevation/shadowColor на рівні кожного рядка).

4. Попереднє обчислення геометрії (getItemLayout)

За замовчуванням список обчислює координати кожного рядка динамічно через асинхронні вимірювання нативного шару (Layout Measurement Phase). Якщо всі рядки мають однакову фіксовану висоту, функція getItemLayout дозволяє повністю пропустити цю фазу, миттєво обчислюючи зміщення за формулою $O(1)$:

const ITEM_HEIGHT = 80;
const SEPARATOR_HEIGHT = 8;

const getItemLayout = (data: any, index: number) => ({
  length: ITEM_HEIGHT,
  offset: (ITEM_HEIGHT + SEPARATOR_HEIGHT) * index,
  index,
});

5. Інвалідація віртуалізації через extraData

Оскільки FlatList оптимізує рендеринг і перевіряє тільки зміни масиву data, зміна зовнішнього стану (наприклад, глобальна зміна теми оформлення чи виділення активного рядка selectedId) не викличе перерендер рядків автоматично. Проп extraData={selectedId} вказує віртуалізатору на необхідність інвалідації кешу при зміні цього значення.

const renderItem = useCallback(
  ({ item }: { item: Trip }) => (
    <TripCard trip={item} onPress={handleOpenTrip} />
  ),
  [handleOpenTrip],
);
Чому extraData рятує від застарілих замикань. Якщо передати selectedId всередину TripCard, але не вказати extraData={selectedId}, FlatList порівняє посилання на масив data (яке не змінилося) і пропустить повторний рендеринг видимих рядків. Завжди транслюйте в extraData будь-який стан поза data, від якого залежить візуальний вигляд елементів.

FlashList — парадигма Cell Recycling

Хоча FlatList покриває більшість базових потреб, на складних інтерфейсах (стрічки соціальних мереж із гетерогенним контентом, динамічними медіа та швидким скролом) він демонструє фундаментальний архітектурний недолік: циклічне створення та видалення вузлів.

Коли елемент виходить за межі буфера, FlatList повністю демонтує компонент React і знищує нативний UIView/ViewGroup. Коли користувач скролить назад або вперед, пам'ять під цей компонент виділяється наново. Це призводить до постійного навантаження на CPU, стрибків виділення пам'яті та роботи Garbage Collector.

Архітектура рециклінгу (Cell Recycling)

Бібліотека @shopify/flash-list кардинально змінює парадигму роботи з пам'яттю, реалізуючи підхід Cell Recycling (аналогічно до нативних UICollectionView в iOS та RecyclerView в Android):

  1. FlashList створює у нативній пам'яті лише мінімально необхідний пул контейнерів (наприклад, 10–15 комірок, достатніх для заповнення екрана та буфера).
  2. Коли комірка виходить за верхню межу екрана, вона не знищується. Її нативне представлення переміщується в нижню частину списку.
  3. React не монтує новий компонент, а виконує швидке оновлення властивостей (props reconciliation) у вже існуючому екземплярі.
  4. В результаті кількість операцій створення/знищення нативних вузлів зводиться майже до нуля, забезпечуючи приріст продуктивності у 5–10 разів.

Порівняльний аналіз FlatList та FlashList

Критерій порівнянняFlatList (Core React Native)FlashList (@shopify/flash-list)
Архітектурна модельUnmount / Remount (знищення та створення)Cell Recycling (перевикористання нативних комірок)
Продуктивність при швидкому скроліСхильний до появи білих зон (blank areas) та jankСтабільні 60/120 FPS без просідань
Навантаження на пам'ять і GCХвилеподібне (постійні алокації та збір сміття)Стабільне $O(1)$ з константним пулом вузлів
Залежність від платформиВбудований у React Native (чистий JS/TS + Core)Зовнішній пакет з нативними біндінг-оптимізаціями
Підтримка в браузері (react-native-web)Повна підтримка з коробкиВимагає нативного рантайму або polyfill

Встановлення та інтеграція

npx expo install @shopify/flash-list

API FlashList спеціально спроєктовано як максимально наближене до FlatList, тому міграція зазвичай потребує лише заміни імпорту:

// 1. Початковий варіант:
import { FlatList } from 'react-native';
<FlatList data={trips} renderItem={renderTrip} keyExtractor={keyExtractor} />

// 2. Оптимізований варіант з FlashList:
import { FlashList } from '@shopify/flash-list';
<FlashList data={trips} renderItem={renderTrip} keyExtractor={keyExtractor} />
У першій мажорній версії FlashList обов'язково вимагав передачі пропу estimatedItemSize (приблизної середньої висоти комірки в dp, наприклад estimatedItemSize={220}). Без цього параметра компонування списку могло зазнавати різких стрибків під час розрахунку полотна.

Гетерогенні списки та типізація комірок (getItemType)

У складних стрічках контент часто є неоднорідним: наприклад, звичайна картка поїздки з фото (type: 'trip'), повноширинний рекламний банер (type: 'ad') або коротка текстова цитата (type: 'quote').

Якщо не повідомити FlashList про структурну різницю між елементами, рециклінг намагатиметься перетворити існуючий нативний контейнер простої цитати на важку картку з фотографією. Це змушує рушій демонтувати одні нативні піддерева і монтувати інші безпосередньо під час прокручування, руйнуючи переваги рециклінгу.

Для оптимізації гетерогенних списків використовується проп getItemType:

type FeedItem =
  | { type: 'trip'; id: string; title: string; coverUri: string }
  | { type: 'quote'; id: string; text: string }
  | { type: 'ad'; id: string; bannerUrl: string };

export function HeterogeneousFeed({ items }: { items: FeedItem[] }) {
  return (
    <FlashList
      data={items}
      keyExtractor={(item) => item.id}
      getItemType={(item) => item.type}
      renderItem={({ item }) => {
        switch (item.type) {
          case 'trip':
            return <TripCard trip={item} />;
          case 'quote':
            return <QuoteCard quote={item} />;
          case 'ad':
            return <AdBanner ad={item} />;
        }
      }}
    />
  );
}
Ізольовані пули рециклінгу. Завдяки getItemType бібліотека FlashList створює окремий незалежний пул нативних комірок для кожного типу. Контейнери від рекламних банерів перевикористовуються виключно для нових рекламних банерів, а картки поїздок — для поїздок, гарантуючи нульові витрати на перебудову компоновки.

Класифікація типових антипатернів

АнтипатернМеханізм виникненняНаслідки для системиКоректне інженерне рішення
ScrollView + map для великих масивівРендеринг усього масиву без віртуалізації.Стрибок споживання RAM, блокування UI-потоку, OOM Crash.Застосування FlatList або FlashList.
Використання index у keyExtractorКлюч прив'язаний до позиції, а не до сутності.Витік стану, некоректні анімації, поломка рециклінгу.Використання незмінного item.id.
Анонімні стрілочні функції в renderItemСтворення нового посилання на кожному рендері батька.Неможливість мемоізації через React.memo, надлишковий ререндер.Огортання обробників у useCallback та винесення рядка.
Список у контейнері без фіксованої висотиВідсутність обмежень у компоновці Flexbox.Список розгортає всі 10 000 рядків, втрачаючи скрол.Забезпечення ланцюжка flex: 1 від кореневого екрана.
Зведення помилки та порожнечі до ListEmptyComponentВідсутність скінченного автомата станів.Користувач бачить «Порожньо» під час відсутності інтернету.Чітке розділення станів loading, error, empty, success.
Пагінація без блокування стану гонитвиВиклик onEndReached під час активного запиту.Дублювання сторінок, спам мережевими запитами.Встановлення захисних прапорців isLoadingMore та hasMore.
Локальний useState у комірці FlashListЗбереження стану в рецикльованому контейнері.«Перетікання» прапорців на інші елементи масиву.Нормалізований стан у батьківському сховищі за item.id.

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

Мета та архітектурні вимоги

Метою практичного міні-проєкту є консолідація отриманих знань про ієрархічні структури даних, віртуалізацію та обробку станів інтерфейсу в ізольованому навчальному екрані.

Застосунок повинен реалізувати:

  1. Генерацію великого набору даних: 200+ сутностей книг із полями id, title, author, genre, year, rating.
  2. Двовимірну кластеризацію: динамічне групування плоского масиву в масив секцій BookSection[] за допомогою алгоритму toSections.
  3. Віртуалізоване відображення (SectionList): підтримка липких заголовків жанрів (stickySectionHeadersEnabled) та розділювачів елементів.
  4. Асинхронний життєвий цикл (Pull-to-refresh): імітація оновлення рейтингів та синхронізації з сервером через <RefreshControl />.
  5. Обробку порожнього стану: коректне центрування та поведінка ListEmptyComponent при повному очищенні колекції.

Модель даних

interface Book {
  id: string;
  title: string;
  author: string;
  genre: string;
  year: number;
  rating: number;
}

interface BookSection {
  title: string;
  data: Book[];
}

Поетапний план реалізації

1. Генерація детермінованого набору mock-даних

Створюємо чисту функцію buildBooks(seed: number, count = 220), яка на основі seed-значення генерує масив книг із розподілом за 5 жанрами та псевдовипадковими рейтингами.

2. Алгоритм трансформації даних у секційну структуру

Реалізуємо чисту функцію toSections(books: Book[]): BookSection[] на базі структури даних Map<string, Book[]>. Функція групує книги за жанром, сортує назви жанрів в алфавітному порядку за локаллю uk та повертає нормалізований масив для SectionList. Результат мемоізуємо через useMemo(() => toSections(books), [books]).

3. Конфігурація компонента SectionList

Визначаємо стабільні функції renderItem, renderSectionHeader, keyExtractor та налаштовуємо властивість stickySectionHeadersEnabled. Додаємо ItemSeparatorComponent для забезпечення візуального ритму карток.

4. Інтеграція механізму Pull-to-refresh

Підключаємо <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />. Під час спрацьовування жесту інкрементуємо seed, ініціюємо таймер затримки (імітація мережі) та оновлюємо стан books.

5. Обробка порожнього стану та центрування

Забезпечуємо перемикання стилю contentContainerStyle={books.length === 0 ? styles.grow : { paddingBottom: 24 }} для забезпечення ідеального вертикального центрування ListEmptyComponent.

6. Верифікація продуктивності

Переконуємося у відсутності просідання FPS під час швидкої інерційної прокрутки 200+ елементів та стабільності роботи липких заголовків.

Повний еталонний код реалізації

Нижче наведено вичерпний самодостатній код екрана App.tsx. Розгорніть блок для детального аналізу або копіювання:

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

TSXBookCatalog.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Nomad: архітектура комбінованого віртуалізованого екрана

Архітектурна мотивація

У міру розвитку функціоналу щоденника подорожей Nomad статичний рендеринг через ScrollView + map стає неприпустимим: зростає кількість поїздок, додаються високоякісні фотообкладинки та з'являється новий бізнес-модуль «Останні відвідані місця» з горизонтальним скролом.

Головний екран Nomad вимагає побудови комбінованого віртуалізованого макета (Compound Virtualized Layout):

  1. Фіксований верхній блок (Sticky Shell Header): брендинг, статус теми та селектор ThemeProvider (закріплені над списком).
  2. Головна вертикальна стрічка поїздок: реалізована на базі високопродуктивного FlashList для забезпечення рециклінгу карток.
  3. Вкладений горизонтальний каталог місць: реалізований через компактний FlatList horizontal, вбудований у ListHeaderComponent основного списку. Таке компонування повністю виключає конфлікти жестів (Nested Scroll Conflicts), оскільки списки мають взаємно перпендикулярні осі прокручування.
  4. Фіксований підвал (Sticky CTA): закріплена кнопка «Нова поїздка», розташована за межами скрол-контейнера.

Еволюція кодової бази проєкту

Успадковано з попередніх розділів:

  • Модуль теми (ThemeProvider, useTheme, селектор кольорової схеми system/light/dark).
  • Базовий каркас екрана (Screen, AppText, Button).
  • Презентаційний шар поїздок (TripCard, модель Trip).

Архітектурні нововведення цього розділу:

  • Інтеграція @shopify/flash-list як основного рушія рендерингу поїздок.
  • Розширення моделі даних: типи Place, mock-набір mockPlaces, компонент PlaceChip.
  • Застосування React.memo для компонентів TripCard та PlaceChip для запобігання зайвим фазам перемальовування.
  • Інтеграція системного жесту оновлення <RefreshControl /> та декларативного порожнього стану ListEmptyComponent.

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

Готовий код 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