Списки та віртуалізація
Списки та віртуалізація
Навіщо ця стаття
У мобільних застосунках списки є домінуючим патерном представлення інформації: стрічки соціальних мереж, каталоги інтернет-магазинів, списки транзакцій, чати та аудіоплеєри побудовані на базі прокручуваних масивів елементів. У навчальних прикладах часто оперують невеликими масивами з 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, обробка помилок та порожніх станів).
Після опрацювання матеріалу ви зможете:
- Глибоко розуміти фізичну різницю між нативним рендерингом
ScrollView + mapта віртуалізованим віконним відображенням. - Проєктувати високоефективні компоненти на базі
FlatList, бездоганно налаштовуючи зв'язкуdata,renderItem,keyExtractorта параметри буферизації. - Реалізовувати повноцінний UX-контракт списку: відображення порожнього стану (
ListEmptyComponent), композицію шапки (ListHeaderComponent) та підвалу (ListFooterComponent), а також двонаправлене оновлення за допомогою жестуRefreshControl. - Конструювати надійні механізми нескінченної пагінації (
onEndReached), коректно контролюючи порогові значення та запобігаючи стану гонитви (race conditions). - Структурувати складні ієрархічні дані за допомогою
SectionList, налаштовуючи поведінку закріплення заголовків (stickySectionHeadersEnabled) з урахуванням специфіки iOS та Android. - Аналізувати та усувати вузькі місця продуктивності, застосовуючи мемоізацію рядків (
React.memo), стабільні посилання (useCallback), попереднє обчислення геометрії (getItemLayout) та зовнішній стан (extraData). - Оцінювати доцільність переходу на
FlashList, розуміючи концептуальну різницю між циклічним видаленням/монтуванням вузлів та парадигмою рециклінгу комірок (Cell Recycling). - Інтегрувати багаторівневі списки в архітектуру реального проєкту на прикладі стрічки поїздок та горизонтального каталогу місць у застосунку Nomad.
::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/virtual | FlatList / SectionList / FlashList | Віртуалізований рендеринг. Створюються лише елементи, наближені до поточної координати прокручування. |
IntersectionObserver (Infinite Scroll) | onEndReached у списках | Розрахунок відносного зміщення скролу для завчасного підвантаження порцій даних із сервера. |
| CSS pull-to-refresh / Touch events | RefreshControl | Пряма обгортка над системними віджетами оновлення (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).
Фундаментальні концепції віртуалізації списків
Віртуалізація списків (List Virtualization / Windowing) — це алгоритмічний підхід до рендерингу великих або потенційно нескінченних наборів даних, за якого кількість створених візуальних елементів інтерфейсу пропорційна розміру видимої області екрана, а не загальній довжині вхідного масиву даних.
У React Native базовим вбудованим інструментом віртуалізації є компонент FlatList, побудований поверх низькорівневого абстрактного компонента VirtualizedList.
Для розуміння математики віртуалізованого списку необхідно виділити ключові сутності:
contentOffset.y) і динамічно обчислює індекси елементів [startIndex, endIndex], які потрапляють у цю область.- Unmount (FlatList): елементи, що виходять за межі буфера, повністю видаляються з дерева React і нативного дерева, звільняючи пам'ять. При повторному скролі вони інстанціюються наново.
- Recycle (FlashList): фізичні нативні представлення не знищуються, а зберігаються в пулі (pool) і повторно заповнюються новими даними (
props), що мінімізує алокацію пам'яті та навантаження на GC.
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:
FlatList здійснює неглибоке порівняння (shallow comparison) посилання на масив data. Зміна вмісту масиву без зміни посилання не викличе перерендер списку.useCallback або винесена за межі рендера батьківського компонента), щоб уникнути непотрібної інвалідації всіх видимих рядків.FlatList перевіряє наявність поля item.key або item.id. Явне визначення keyExtractor є обов'язковим стандартом, якщо структура ваших даних відрізняється від дефолтної.data порожній (data.length === 0). Дозволяє декларативно описувати стан відсутності даних.margin або ліній розділення.padding) списку та властивості flexGrow: 1.flex: 1 для обмеження висоти списку розмірами батьківського екрана.extraData. Оскільки renderItem оптимізовано через PureComponent/memo, без зміни extraData список проігнорує оновлення зовнішніх залежностей.<RefreshControl /> для асинхронного перезавантаження вибірки.0 до 1) для реалізації пагінації. Значення 0.5 означає, що функція onEndReached спрацює, коли нижня межа списку буде на відстані половини висоти вікна перегляду від кінця контенту.UIView/View) від віконної ієрархії на рівні ОС. Суттєво знижує споживання VRAM, але на iOS іноді може викликати тимчасові артефакти зникнення складного вкладеного контенту або текстових полів.numColumns > 1 (наприклад, для рівномірного розподілу карток через justifyContent: 'space-between').onEndReached генерується при наближенні до верхнього краю. Базовий патерн для екранів чатів та стрічок повідомлень.Демо: простий FlatList
Loading…
react-native-web · not a real device
import { FlatList, StyleSheet, Text, View, useColorScheme } from 'react-native';
type Row = { id: string; title: string; subtitle: string };
const DATA: Row[] = Array.from({ length: 40 }, (_, i) => ({
id: String(i + 1),
title: `Поїздка ${i + 1}`,
subtitle: i % 2 === 0 ? 'Гори' : 'Місто',
}));
export default function App() {
const dark = useColorScheme() === 'dark';
const bg = dark ? '#000' : '#F8FAFC';
const surface = dark ? '#1c1c1e' : '#fff';
const text = dark ? '#f5f5f7' : '#0f172a';
const muted = dark ? '#a1a1aa' : '#64748b';
const border = dark ? '#3a3a3c' : '#e2e8f0';
return (
<View style={[styles.screen, { backgroundColor: bg }]}>
<Text style={[styles.h, { color: text }]}>FlatList · 40 рядків</Text>
<Text style={{ color: muted, marginBottom: 8, fontSize: 13 }}>
У дереві — лише видимі. Прокрутіть список.
</Text>
<FlatList
style={styles.list}
data={DATA}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 16, gap: 8 }}
renderItem={({ item }) => (
<View
style={[
styles.row,
{ backgroundColor: surface, borderColor: border },
]}
>
<Text style={[styles.title, { color: text }]}>{item.title}</Text>
<Text style={{ color: muted, fontSize: 13 }}>{item.subtitle}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, paddingTop: 24 },
h: { fontSize: 20, fontWeight: '800', marginBottom: 4 },
list: { flex: 1 },
row: {
borderWidth: 1,
borderRadius: 12,
padding: 14,
},
title: { fontSize: 16, fontWeight: '700', marginBottom: 4 },
});
ScrollView, віртуалізований список вимагає жорстко обмеженої геометрії батьківського контейнера вздовж осі прокручування (зазвичай через flex: 1 у всьому ланцюжку: Screen → View-обгортка → FlatList). Якщо список помістити в батьківський блок із необмеженою висотою (наприклад, усередину іншого вертикального ScrollView або в контейнер з автоматичною висотою height: 'auto'), FlatList не зможе визначити межі фізичного вікна перегляду. У результаті він спробує розгорнути все своє полотно на повну висоту, змонтувавши абсолютно всі рядки одночасно, що повністю нівелює ефект віртуалізації.keyExtractor: алгоритм узгодження та стабільність ідентичності
У React Native механізм keyExtractor відіграє вирішальну роль у функціонуванні алгоритму узгодження (Reconciliation Algorithm). Під час кожної зміни структури або порядку масиву даних React порівнює попереднє дерево віртуальних вузлів із новим. Ключ (key) є єдиним критерієм, за яким рушій визначає, чи конкретний елемент зберігся, змістився на нову позицію, був доданий або видалений.
// ❌ Категорично заборонено для динамічних або змінних списків:
keyExtractor={(_, index) => String(index)}
// ✅ Інженерний стандарт: унікальний та незмінний бізнес-ідентифікатор:
keyExtractor={(item) => item.id}
Чому індекс масиву руйнує роботу списку?
Використання порядкового індексу (index) як ключа є критичним антипатерном, який призводить до трьох категорій дефектів:
- Витік та зміщення внутрішнього стану (Component State Leakage). Якщо компонент рядка містить локальний стан (
useState,useRef, стан розгорнутого акордеона або текст у полі вводу), цей стан прив'язується React до ключа, а не до бізнес-об'єкта. Якщо видалити перший елемент масиву (індекс 0), елемент з колишнім індексом 1 тепер отримує індекс 0. React вважає, що компонент залишився тим самим, і «успадковує» внутрішній стан видаленого елемента для нових даних. - Деградація продуктивності через масовий ререндер. При додаванні нового елемента на початок списку індекси всіх наступних 100 елементів змінюються ($0 \to 1$, $1 \to 2$, ...). React змушений повністю перемалювати все вікно віртуалізації, замість того щоб виконати одну операцію вставки нативного представлення.
- Руйнування рециклінгу та нативних анімацій. У бібліотеках з рециклінгом (
FlashList) або при використанніLayoutAnimationневірні ключі призводять до візуальних артефактів: мерехтіння зображень, некоректного прикріплення асинхронно завантажених текстур та стрибків скролу.
key у renderItem. Ніколи не передавайте проп key={item.id} на кореневий елемент, який повертається з функції renderItem. FlatList автоматично огортає кожен рядок у внутрішній контейнер CellRenderer і самостійно управляє ключами на основі результату функції keyExtractor. Ручне дублювання ключів створює конфлікти в дереві рендерингу та ламає механізми перевірки оновлень.Порожній стан, шапка, підвал та розділювачі
Професійно спроєктований список повинен коректно обробляти всі можливі стани інтерфейсу: початкове завантаження, відображення даних, стан відсутності результатів та структуроване обрамлення.
Архітектурна композиція списку
ListHeaderComponentтаListFooterComponent: інтегруються безпосередньо в координату прокручування. Шапка з'являється над першим елементом, підвал — під останнім. Це ідеальне місце для пошукових панелей, банерів, фільтрів або індикаторів завантаження наступної сторінки.ItemSeparatorComponent: вирішує класичну проблему відступів між плитками. На відміну від статичногоmarginVerticalна картці, розділювач не рендериться перед нульовим елементом і після фінального, гарантуючи ідеальну посадку контенту в межах контейнера.ListEmptyComponent: монтується тоді й лише тоді, колиdata.length === 0(і при цьомуdataне єnull/undefined). Порожній стан — це не екранний збій, а важлива частина комунікації з користувачем (Empty State UX), що пояснює причину відсутності записів і пропонує дію (кнопка «Оновити», «Створити поїздку» тощо).
Loading…
react-native-web · not a real device
import { useState } from 'react';
import {
FlatList,
Pressable,
StyleSheet,
Text,
View,
useColorScheme,
} from 'react-native';
type Row = { id: string; title: string };
export default function App() {
const dark = useColorScheme() === 'dark';
const bg = dark ? '#000' : '#F8FAFC';
const surface = dark ? '#1c1c1e' : '#fff';
const text = dark ? '#f5f5f7' : '#0f172a';
const muted = dark ? '#a1a1aa' : '#64748b';
const primary = dark ? '#3b82f6' : '#2563eb';
const border = dark ? '#3a3a3c' : '#e2e8f0';
const [data, setData] = useState<Row[]>([]);
return (
<View style={[styles.screen, { backgroundColor: bg }]}>
<View style={styles.toolbar}>
<Pressable
onPress={() =>
setData((prev) => [
...prev,
{ id: String(Date.now()), title: `Книга ${prev.length + 1}` },
])
}
style={[styles.btn, { backgroundColor: primary }]}
>
<Text style={styles.btnText}>Додати</Text>
</Pressable>
<Pressable
onPress={() => setData([])}
style={[styles.btn, { backgroundColor: surface, borderColor: border, borderWidth: 1 }]}
>
<Text style={{ color: text, fontWeight: '700' }}>Очистити</Text>
</Pressable>
</View>
<FlatList
style={{ flex: 1 }}
data={data}
keyExtractor={(item) => item.id}
contentContainerStyle={
data.length === 0 ? styles.emptyContainer : { paddingBottom: 16, gap: 8 }
}
ListHeaderComponent={
<Text style={[styles.h, { color: text }]}>Моя полиця</Text>
}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={[styles.emptyTitle, { color: text }]}>Порожньо</Text>
<Text style={{ color: muted, textAlign: 'center', lineHeight: 20 }}>
Натисніть «Додати» — з’явиться перший рядок. ListEmptyComponent
замінює «нічого».
</Text>
</View>
}
renderItem={({ item }) => (
<View style={[styles.row, { backgroundColor: surface, borderColor: border }]}>
<Text style={{ color: text, fontWeight: '600' }}>{item.title}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, paddingTop: 24 },
toolbar: { flexDirection: 'row', gap: 8, marginBottom: 12 },
btn: {
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
},
btnText: { color: '#fff', fontWeight: '700' },
h: { fontSize: 18, fontWeight: '800', marginBottom: 12 },
emptyContainer: { flexGrow: 1 },
empty: {
flex: 1,
minHeight: 220,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingHorizontal: 12,
},
emptyTitle: { fontSize: 18, fontWeight: '700' },
row: {
borderWidth: 1,
borderRadius: 12,
padding: 14,
},
});
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 === true, системний спінер залишається видимим та обертається, навіть якщо користувач уже відпустив палець від екрана.refreshing = true та ініціюється мережевий запит. Після завершення операції (успіх або помилка) прапорець обов'язково скидається у false.tintColor, на Android — масивом шістнадцяткових кодів colors={[primaryColor]}, що визначає кольорову послідовність дуги індикатора.Демо: оновлення списку
Loading…
react-native-web · not a real device
import { useCallback, useState } from 'react';
import {
FlatList,
RefreshControl,
StyleSheet,
Text,
View,
useColorScheme,
} from 'react-native';
type Row = { id: string; title: string; stamp: number };
function makeRows(seed: number): Row[] {
return Array.from({ length: 12 }, (_, i) => ({
id: `${seed}-${i}`,
title: `Елемент ${i + 1}`,
stamp: seed,
}));
}
export default function App() {
const dark = useColorScheme() === 'dark';
const bg = dark ? '#000' : '#F8FAFC';
const surface = dark ? '#1c1c1e' : '#fff';
const text = dark ? '#f5f5f7' : '#0f172a';
const muted = dark ? '#a1a1aa' : '#64748b';
const primary = dark ? '#3b82f6' : '#2563eb';
const border = dark ? '#3a3a3c' : '#e2e8f0';
const [seed, setSeed] = useState(1);
const [data, setData] = useState(() => makeRows(1));
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
const next = seed + 1;
setSeed(next);
setData(makeRows(next));
setRefreshing(false);
}, 800);
}, [seed]);
return (
<View style={[styles.screen, { backgroundColor: bg }]}>
<Text style={[styles.h, { color: text }]}>Потягніть униз</Text>
<Text style={{ color: muted, marginBottom: 8, fontSize: 13 }}>
Оновлення №{seed} · mock-затримка 800 ms
</Text>
<FlatList
style={{ flex: 1 }}
data={data}
keyExtractor={(item) => item.id}
contentContainerStyle={{ gap: 8, paddingBottom: 16 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={primary}
colors={[primary]}
/>
}
renderItem={({ item }) => (
<View
style={[styles.row, { backgroundColor: surface, borderColor: border }]}
>
<Text style={{ color: text, fontWeight: '600' }}>{item.title}</Text>
<Text style={{ color: muted, fontSize: 12 }}>batch {item.stamp}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, paddingTop: 24 },
h: { fontSize: 20, fontWeight: '800' },
row: {
borderWidth: 1,
borderRadius: 12,
padding: 14,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
});
У прев’ю потягніть список униз (на тачпаді / з зажатою кнопкою миші). На реальному телефоні жест природніший.
Мережевий шар (справжній 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 стає джерелом критичних багів:
- Хибний старт при ініціалізації. Якщо початковий масив містить лише 2–3 елементи, висота контенту $H_{\text{content}}$ може бути меншою за висоту екрана $H_{\text{viewport}}$. У цьому випадку умова спрацювання виконується автоматично під час першого монтування списку.
- Паралельні дублюючі запити (Duplicate Triggers). Під час швидкого інерційного скролу подія
onEndReachedможе викликатися багаторазово за короткий проміжок часу. Якщо перший асинхронний запит ще перебуває в процесі виконання, запуск другого запиту призведе до дублювання сторінок або розсинхронізації курсорів пагінації.
if (isLoadingMore || !hasMore) return;Стан
isLoadingMore встановлюється в true перед запитом і скидається в finally-блоці, а hasMore визначається відповіддю сервера (наявність наступної сторінки або досягнення ліміту).Демо: «ще 10», коли внизу
Loading…
react-native-web · not a real device
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
FlatList,
StyleSheet,
Text,
View,
useColorScheme,
} from 'react-native';
type Row = { id: string; title: string };
const PAGE = 10;
const MAX = 50;
function pageItems(from: number, count: number): Row[] {
return Array.from({ length: count }, (_, i) => {
const n = from + i + 1;
return { id: String(n), title: `Книга №${n}` };
});
}
export default function App() {
const dark = useColorScheme() === 'dark';
const bg = dark ? '#000' : '#F8FAFC';
const surface = dark ? '#1c1c1e' : '#fff';
const text = dark ? '#f5f5f7' : '#0f172a';
const muted = dark ? '#a1a1aa' : '#64748b';
const primary = dark ? '#3b82f6' : '#2563eb';
const border = dark ? '#3a3a3c' : '#e2e8f0';
const [data, setData] = useState(() => pageItems(0, PAGE));
const [loadingMore, setLoadingMore] = useState(false);
const hasMore = data.length < MAX;
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
setTimeout(() => {
setData((prev) => {
const next = pageItems(prev.length, Math.min(PAGE, MAX - prev.length));
return [...prev, ...next];
});
setLoadingMore(false);
}, 700);
}, [loadingMore, hasMore]);
return (
<View style={[styles.screen, { backgroundColor: bg }]}>
<Text style={[styles.h, { color: text }]}>Пагінація UI</Text>
<Text style={{ color: muted, marginBottom: 8, fontSize: 13 }}>
{data.length} / {MAX} · долистайте вниз
</Text>
<FlatList
style={{ flex: 1 }}
data={data}
keyExtractor={(item) => item.id}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
contentContainerStyle={{ gap: 8, paddingBottom: 16 }}
ListFooterComponent={
loadingMore ? (
<View style={styles.footer}>
<ActivityIndicator color={primary} />
<Text style={{ color: muted, marginTop: 8 }}>Завантаження…</Text>
</View>
) : !hasMore ? (
<Text style={{ color: muted, textAlign: 'center', padding: 12 }}>
Це всі книги (mock)
</Text>
) : null
}
renderItem={({ item }) => (
<View
style={[styles.row, { backgroundColor: surface, borderColor: border }]}
>
<Text style={{ color: text, fontWeight: '600' }}>{item.title}</Text>
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, paddingTop: 24 },
h: { fontSize: 20, fontWeight: '800' },
row: { borderWidth: 1, borderRadius: 12, padding: 14 },
footer: { paddingVertical: 16, alignItems: 'center' },
});
Відстеження видимості елементів: viewabilityConfig та аналітика показів
У багатьох мобільних застосунках недостатньо просто відрендерити список — необхідно точно знати, які саме елементи користувач фактично бачить на екрані в конкретний момент часу.
Типові сценарії використання:
- Автоматичне відтворення медіа: запуск відеоролика, коли картка з'являється у видимій області на 60%+, та пауза при виході за межі екрана (патерн стрічок Instagram/TikTok).
- Маркетингова аналітика показів (Ad Impressions): фіксація факту перегляду рекламного банера за стандартом IAB (наприклад, елемент видно на 50%+ протягом щонайменше 1000 мс).
- Оновлення навігаційних індикаторів: підсвічування активного розділу або дати при прокручуванні стрічки.
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).false, оскільки історично в Android Material Guidelines прилипання заголовків у RecyclerView не було стандартним патерном. Якщо дизайн-система вимагає липких заголовків на обох платформах, встановіть stickySectionHeadersEnabled={true} явно та перевірте відсутність артефактів накладання тіней.::
Демо: секції
Loading…
react-native-web · not a real device
import {
SectionList,
StyleSheet,
Text,
View,
useColorScheme,
} from 'react-native';
type Book = { id: string; title: string; author: string };
const SECTIONS: { title: string; data: Book[] }[] = [
{
title: 'Фантастика',
data: [
{ id: '1', title: 'Дюна', author: 'Ф. Герберт' },
{ id: '2', title: 'Ліва рука темряви', author: 'У. Ле Ґуїн' },
{ id: '3', title: 'Нейромант', author: 'В. Ґібсон' },
],
},
{
title: 'Детектив',
data: [
{ id: '4', title: 'Ім’я троянди', author: 'У. Еко' },
{ id: '5', title: 'Дівчина з тату', author: 'С. Ларссон' },
],
},
{
title: 'Подорожі',
data: [
{ id: '6', title: 'Дорога', author: 'Дж. Керуак' },
{ id: '7', title: 'Холод', author: 'А. Камю' },
{ id: '8', title: 'Nomad notes', author: 'Анонім' },
],
},
];
export default function App() {
const dark = useColorScheme() === 'dark';
const bg = dark ? '#000' : '#F8FAFC';
const surface = dark ? '#1c1c1e' : '#fff';
const text = dark ? '#f5f5f7' : '#0f172a';
const muted = dark ? '#a1a1aa' : '#64748b';
const primary = dark ? '#3b82f6' : '#2563eb';
const border = dark ? '#3a3a3c' : '#e2e8f0';
const headerBg = dark ? '#111' : '#e2e8f0';
return (
<View style={[styles.screen, { backgroundColor: bg }]}>
<Text style={[styles.h, { color: text }]}>Каталог · SectionList</Text>
<SectionList
style={{ flex: 1 }}
sections={SECTIONS}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled
contentContainerStyle={{ paddingBottom: 24 }}
renderSectionHeader={({ section: { title } }) => (
<View style={[styles.sec, { backgroundColor: headerBg }]}>
<Text style={{ color: primary, fontWeight: '800' }}>{title}</Text>
</View>
)}
renderItem={({ item }) => (
<View
style={[styles.row, { backgroundColor: surface, borderColor: border }]}
>
<Text style={{ color: text, fontWeight: '700' }}>{item.title}</Text>
<Text style={{ color: muted, marginTop: 4 }}>{item.author}</Text>
</View>
)}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
SectionSeparatorComponent={() => <View style={{ height: 12 }} />}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, paddingTop: 24 },
h: { fontSize: 20, fontWeight: '800', marginBottom: 12 },
sec: {
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 8,
marginBottom: 8,
},
row: {
borderWidth: 1,
borderRadius: 12,
padding: 14,
},
});
Продуктивність списків та оптимізація рендерингу рядка
Віртуалізація списку вирішує проблему кількості одночасно змонтованих елементів, але не нівелює обчислювальної складності рендерингу окремого рядка.
У мобільній розробці інтерфейс має оновлюватися із частотою 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):
FlashListстворює у нативній пам'яті лише мінімально необхідний пул контейнерів (наприклад, 10–15 комірок, достатніх для заповнення екрана та буфера).- Коли комірка виходить за верхню межу екрана, вона не знищується. Її нативне представлення переміщується в нижню частину списку.
- React не монтує новий компонент, а виконує швидке оновлення властивостей (
props reconciliation) у вже існуючому екземплярі. - В результаті кількість операцій створення/знищення нативних вузлів зводиться майже до нуля, забезпечуючи приріст продуктивності у 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}). Без цього параметра компонування списку могло зазнавати різких стрибків під час розрахунку полотна.estimatedItemSize було усунено, а розміри та зміщення обчислюються автоматично та адаптивно в режимі реального часу.Гетерогенні списки та типізація комірок (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
Мета та архітектурні вимоги
Метою практичного міні-проєкту є консолідація отриманих знань про ієрархічні структури даних, віртуалізацію та обробку станів інтерфейсу в ізольованому навчальному екрані.
Застосунок повинен реалізувати:
- Генерацію великого набору даних: 200+ сутностей книг із полями
id,title,author,genre,year,rating. - Двовимірну кластеризацію: динамічне групування плоского масиву в масив секцій
BookSection[]за допомогою алгоритмуtoSections. - Віртуалізоване відображення (
SectionList): підтримка липких заголовків жанрів (stickySectionHeadersEnabled) та розділювачів елементів. - Асинхронний життєвий цикл (Pull-to-refresh): імітація оновлення рейтингів та синхронізації з сервером через
<RefreshControl />. - Обробку порожнього стану: коректне центрування та поведінка
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. Розгорніть блок для детального аналізу або копіювання:
import { useCallback, useMemo, useState } from 'react';
import {
Pressable,
RefreshControl,
SectionList,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
type Book = {
id: string;
title: string;
author: string;
genre: string;
year: number;
rating: number;
};
type BookSection = { title: string; data: Book[] };
const GENRES = ['Фантастика', 'Детектив', 'Подорожі', 'Історія', 'Поезія'] as const;
const AUTHORS = ['Коваленко', 'Шевченко', 'Лис', 'Мельник', 'Бондар'];
function buildBooks(seed: number, count = 220): Book[] {
return Array.from({ length: count }, (_, i) => {
const genre = GENRES[i % GENRES.length];
return {
id: `${seed}-${i}`,
title: `Книга ${i + 1}`,
author: AUTHORS[i % AUTHORS.length],
genre,
year: 1990 + (i % 35),
rating: ((seed + i) % 50) / 10 + 5, // 5.0–9.9
};
});
}
function toSections(books: Book[]): BookSection[] {
const map = new Map<string, Book[]>();
for (const b of books) {
const list = map.get(b.genre) ?? [];
list.push(b);
map.set(b.genre, list);
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b, 'uk'))
.map(([title, data]) => ({ title, data }));
}
export default function App() {
const [seed, setSeed] = useState(1);
const [books, setBooks] = useState(() => buildBooks(1));
const [refreshing, setRefreshing] = useState(false);
const sections = useMemo(() => toSections(books), [books]);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setSeed((s) => {
const next = s + 1;
setBooks(buildBooks(next));
return next;
});
setRefreshing(false);
}, 700);
}, []);
return (
<SafeAreaView style={styles.safe}>
<View style={styles.toolbar}>
<Text style={styles.h}>Каталог книг</Text>
<Text style={styles.muted}>
{books.length} книг · seed {seed}
</Text>
<View style={styles.row}>
<Pressable
style={styles.btn}
onPress={() => {
setBooks(buildBooks(seed));
}}
>
<Text style={styles.btnText}>Заповнити</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnGhost]}
onPress={() => setBooks([])}
>
<Text style={styles.btnGhostText}>Очистити</Text>
</Pressable>
</View>
</View>
<SectionList
sections={sections}
style={styles.list}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
contentContainerStyle={
books.length === 0 ? styles.grow : { paddingBottom: 24 }
}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyTitle}>Полиця порожня</Text>
<Text style={styles.muted}>Натисніть «Заповнити» або pull-to-refresh.</Text>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View style={styles.sec}>
<Text style={styles.secText}>
{title} · {data.length}
</Text>
</View>
)}
renderItem={({ item }) => (
<View style={styles.card}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.muted}>
{item.author} · {item.year} · ★ {item.rating.toFixed(1)}
</Text>
</View>
)}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
toolbar: { padding: 16, gap: 6, borderBottomWidth: 1, borderColor: '#E2E8F0' },
h: { fontSize: 22, fontWeight: '800', color: '#0F172A' },
muted: { color: '#64748B', fontSize: 13 },
row: { flexDirection: 'row', gap: 8, marginTop: 8 },
btn: {
backgroundColor: '#2563EB',
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
},
btnText: { color: '#fff', fontWeight: '700' },
btnGhost: { backgroundColor: '#fff', borderWidth: 1, borderColor: '#E2E8F0' },
btnGhostText: { color: '#0F172A', fontWeight: '700' },
list: { flex: 1, paddingHorizontal: 16, paddingTop: 12 },
grow: { flexGrow: 1 },
empty: { flex: 1, minHeight: 240, alignItems: 'center', justifyContent: 'center', gap: 8 },
emptyTitle: { fontSize: 18, fontWeight: '700', color: '#0F172A' },
sec: {
backgroundColor: '#E2E8F0',
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 8,
marginBottom: 8,
},
secText: { fontWeight: '800', color: '#2563EB' },
card: {
backgroundColor: '#fff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E2E8F0',
padding: 14,
},
title: { fontWeight: '700', color: '#0F172A', marginBottom: 4 },
});
Живе прев’ю (після кроків)
Loading…
react-native-web · not a real device
import { useCallback, useMemo, useState } from 'react';
import {
Pressable,
RefreshControl,
SectionList,
StyleSheet,
Text,
View,
useColorScheme,
} from 'react-native';
type Book = {
id: string;
title: string;
author: string;
genre: string;
year: number;
rating: number;
};
const GENRES = ['Фантастика', 'Детектив', 'Подорожі', 'Історія', 'Поезія'] as const;
const AUTHORS = ['Коваленко', 'Шевченко', 'Лис', 'Мельник', 'Бондар'];
function buildBooks(seed: number, count = 80): Book[] {
return Array.from({ length: count }, (_, i) => ({
id: `${seed}-${i}`,
title: `Книга ${i + 1}`,
author: AUTHORS[i % AUTHORS.length],
genre: GENRES[i % GENRES.length],
year: 1990 + (i % 35),
rating: ((seed + i) % 50) / 10 + 5,
}));
}
function toSections(books: Book[]) {
const map = new Map<string, Book[]>();
for (const b of books) {
const list = map.get(b.genre) ?? [];
list.push(b);
map.set(b.genre, list);
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b, 'uk'))
.map(([title, data]) => ({ title, data }));
}
export default function App() {
const dark = useColorScheme() === 'dark';
const bg = dark ? '#000' : '#F8FAFC';
const surface = dark ? '#1c1c1e' : '#fff';
const text = dark ? '#f5f5f7' : '#0f172a';
const muted = dark ? '#a1a1aa' : '#64748b';
const primary = dark ? '#3b82f6' : '#2563eb';
const border = dark ? '#3a3a3c' : '#e2e8f0';
const headerBg = dark ? '#111' : '#e2e8f0';
const [seed, setSeed] = useState(1);
const [books, setBooks] = useState(() => buildBooks(1));
const [refreshing, setRefreshing] = useState(false);
const sections = useMemo(() => toSections(books), [books]);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setSeed((s) => {
const next = s + 1;
setBooks(buildBooks(next));
return next;
});
setRefreshing(false);
}, 700);
}, []);
return (
<View style={[styles.safe, { backgroundColor: bg }]}>
<View style={[styles.toolbar, { borderColor: border }]}>
<Text style={[styles.h, { color: text }]}>Каталог книг</Text>
<Text style={{ color: muted, fontSize: 13 }}>
{books.length} · seed {seed} · SectionList
</Text>
<View style={styles.rowBtns}>
<Pressable
style={[styles.btn, { backgroundColor: primary }]}
onPress={() => setBooks(buildBooks(seed))}
>
<Text style={styles.btnText}>Заповнити</Text>
</Pressable>
<Pressable
style={[styles.btn, { backgroundColor: surface, borderColor: border, borderWidth: 1 }]}
onPress={() => setBooks([])}
>
<Text style={{ color: text, fontWeight: '700' }}>Очистити</Text>
</Pressable>
</View>
</View>
<SectionList
style={{ flex: 1 }}
sections={sections}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={primary}
colors={[primary]}
/>
}
contentContainerStyle={
books.length === 0
? { flexGrow: 1, padding: 16 }
: { padding: 16, paddingBottom: 24 }
}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={[styles.emptyTitle, { color: text }]}>Полиця порожня</Text>
<Text style={{ color: muted, textAlign: 'center' }}>
Заповніть або pull-to-refresh
</Text>
</View>
}
renderSectionHeader={({ section: { title, data } }) => (
<View style={[styles.sec, { backgroundColor: headerBg }]}>
<Text style={{ color: primary, fontWeight: '800' }}>
{title} · {data.length}
</Text>
</View>
)}
renderItem={({ item }) => (
<View style={[styles.card, { backgroundColor: surface, borderColor: border }]}>
<Text style={{ color: text, fontWeight: '700' }}>{item.title}</Text>
<Text style={{ color: muted, marginTop: 4, fontSize: 13 }}>
{item.author} · {item.year} · ★ {item.rating.toFixed(1)}
</Text>
</View>
)}
ItemSeparatorComponent={() => <View style={{ height: 8 }} />}
SectionSeparatorComponent={() => <View style={{ height: 10 }} />}
/>
</View>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
toolbar: { padding: 16, gap: 6, borderBottomWidth: 1 },
h: { fontSize: 20, fontWeight: '800' },
rowBtns: { flexDirection: 'row', gap: 8, marginTop: 8 },
btn: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10 },
btnText: { color: '#fff', fontWeight: '700' },
empty: {
flex: 1,
minHeight: 220,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
emptyTitle: { fontSize: 18, fontWeight: '700' },
sec: {
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 8,
marginBottom: 8,
},
card: { borderWidth: 1, borderRadius: 12, padding: 14 },
});
Nomad: архітектура комбінованого віртуалізованого екрана
Архітектурна мотивація
У міру розвитку функціоналу щоденника подорожей Nomad статичний рендеринг через ScrollView + map стає неприпустимим: зростає кількість поїздок, додаються високоякісні фотообкладинки та з'являється новий бізнес-модуль «Останні відвідані місця» з горизонтальним скролом.
Головний екран Nomad вимагає побудови комбінованого віртуалізованого макета (Compound Virtualized Layout):
- Фіксований верхній блок (Sticky Shell Header): брендинг, статус теми та селектор
ThemeProvider(закріплені над списком). - Головна вертикальна стрічка поїздок: реалізована на базі високопродуктивного
FlashListдля забезпечення рециклінгу карток. - Вкладений горизонтальний каталог місць: реалізований через компактний
FlatList horizontal, вбудований уListHeaderComponentосновного списку. Таке компонування повністю виключає конфлікти жестів (Nested Scroll Conflicts), оскільки списки мають взаємно перпендикулярні осі прокручування. - Фіксований підвал (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.
Повний знімок проєкту після цієї статті
git pull у клоні репо. Нижче — весь проєкт на момент цієї статті (усі файли з кодом), не фрагмент «лише нові папки». Клік по файлу в дереві → повний вміст для копіпасту.cd /path/to/nomad
git pull
npm install
npx expo start
Перевірка
- Чіпи теми в шапці залишились і працюють (light/dark/system).
- Стрічка поїздок скролиться (12 карток); місця — горизонтально під шапкою (у header списку).
- Pull-to-refresh — спінер ~0.9 с.
- 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.
Loading…
react-native-web · not a real device
import { useCallback, useState } from 'react';
import {
FlatList,
Image,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
useColorScheme,
} from 'react-native';
type Trip = {
id: string;
title: string;
dateLabel: string;
region: string;
coverUri: string;
};
type Place = { id: string; name: string; city: string };
const light = {
bg: '#F8FAFC', surface: '#fff', text: '#0f172a', muted: '#64748b',
primary: '#2563eb', border: '#e2e8f0', onPrimary: '#fff',
};
const dark = {
bg: '#000', surface: '#1c1c1e', text: '#f5f5f7', muted: '#a1a1aa',
primary: '#3b82f6', border: '#3a3a3c', onPrimary: '#fff',
};
const TRIPS: Trip[] = Array.from({ length: 12 }, (_, i) => ({
id: String(i + 1),
title: ['Карпати', 'Львів', 'Одеса', 'Київ', 'Чернівці', 'Кам’янець',
'Харків', 'Дніпро', 'Ужгород', 'Полтава', 'Затока', 'Яремче'][i],
dateLabel: `${i + 1}–${i + 3} бер. 2026`,
region: i % 2 === 0 ? 'Карпати' : 'Місто',
coverUri: `https://picsum.photos/seed/nomad${i}/800/400`,
}));
const PLACES: Place[] = [
{ id: 'p1', name: 'Говерла', city: 'Карпати' },
{ id: 'p2', name: 'Ринок', city: 'Львів' },
{ id: 'p3', name: 'Ланжерон', city: 'Одеса' },
{ id: 'p4', name: 'Поділ', city: 'Київ' },
{ id: 'p5', name: 'Фортеця', city: 'Кам’янець' },
];
export default function App() {
const systemDark = useColorScheme() === 'dark';
const [pref, setPref] = useState<'system' | 'light' | 'dark'>('system');
const isDark = pref === 'dark' || (pref === 'system' && systemDark);
const c = isDark ? dark : light;
const [trips, setTrips] = useState(TRIPS);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setTrips([...TRIPS]);
setRefreshing(false);
}, 700);
}, []);
const listHeader = (
<View style={{ gap: 12, marginBottom: 12 }}>
<Text style={{ color: c.text, fontWeight: '700' }}>Останні місця</Text>
<FlatList
horizontal
data={PLACES}
keyExtractor={(p) => p.id}
showsHorizontalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
renderItem={({ item }) => (
<View style={[styles.place, { backgroundColor: c.surface, borderColor: c.border }]}>
<Text style={{ color: c.text, fontWeight: '700' }}>{item.name}</Text>
<Text style={{ color: c.primary, marginTop: 4, fontSize: 12 }}>{item.city}</Text>
</View>
)}
/>
<Text style={{ color: c.text, fontWeight: '700' }}>Ваші поїздки</Text>
</View>
);
return (
<View style={[styles.screen, { backgroundColor: c.bg }]}>
<View style={[styles.top, { borderColor: c.border }]}>
<Text style={[styles.brand, { color: c.text }]}>Мандрівник</Text>
<Text style={{ color: c.muted, fontSize: 13, marginTop: 4 }}>
тема: {isDark ? 'темна' : 'світла'} · {trips.length} поїздок · {PLACES.length} місць
</Text>
<View style={styles.chips}>
{([
['system', 'Система'],
['light', 'Світла'],
['dark', 'Темна'],
] as const).map(([id, label]) => {
const on = pref === id;
return (
<Pressable
key={id}
onPress={() => setPref(id)}
style={[
styles.chip,
{
backgroundColor: on ? c.primary : c.surface,
borderColor: c.border,
},
]}
>
<Text style={{ color: on ? c.onPrimary : c.text, fontWeight: '700', fontSize: 12 }}>
{label}
</Text>
</Pressable>
);
})}
</View>
</View>
<FlatList
style={{ flex: 1 }}
data={trips}
keyExtractor={(t) => t.id}
ListHeaderComponent={listHeader}
contentContainerStyle={{ padding: 16, paddingBottom: 12 }}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={c.primary}
colors={[c.primary]}
/>
}
renderItem={({ item }) => (
<View style={[styles.card, { backgroundColor: c.surface, borderColor: c.border }]}>
<Image source={{ uri: item.coverUri }} style={styles.cover} />
<View style={{ padding: 12 }}>
<Text style={{ color: c.text, fontWeight: '700', fontSize: 16 }}>{item.title}</Text>
<Text style={{ color: c.muted, marginTop: 4, fontSize: 13 }}>
{item.dateLabel} · {item.region}
</Text>
</View>
</View>
)}
/>
<View style={[styles.footer, { borderColor: c.border, backgroundColor: c.bg }]}>
<Pressable style={[styles.cta, { backgroundColor: c.primary }]}>
<Text style={{ color: c.onPrimary, fontWeight: '800' }}>Нова поїздка</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1 },
top: { padding: 16, borderBottomWidth: 1 },
brand: { fontSize: 24, fontWeight: '800' },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
chip: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8, borderWidth: 1 },
place: { width: 140, borderWidth: 1, borderRadius: 12, padding: 10, minHeight: 72 },
card: { borderWidth: 1, borderRadius: 16, overflow: 'hidden' },
cover: { width: '100%', height: 120, backgroundColor: '#cbd5e1' },
footer: { borderTopWidth: 1, padding: 16 },
cta: { paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
});
Практичні завдання
Базовий рівень
- Своїми словами пояснити, чим
ScrollView+mapвідрізняється відFlatList. - Зібрати
FlatListна 30 рядків зkeyExtractorпоid. - Додати
ListEmptyComponentі кнопку, що очищуєdata.
Середній рівень
- Міні-проєкт «Каталог книг»: 200+ items,
SectionListза жанром, pull-to-refresh. - У Nomad тимчасово підмінити
FlashListнаFlatListі назад — порівняти код (майже drop-in). - Додати mock
onEndReached: дописувати по 5 поїздок, поки не стане 30.
Професійний рівень
- Error-state: кнопка «Симулювати помилку» ховає список і показує «Спробувати знову».
React.memo+ логіка «чому рядок ререндериться» (тимчасовийconsole.logуTripCard).- Горизонтальний
FlashListмісць замістьFlatList(якщо версія API це зручно підтримує) і нотатка про nested scrolling.
Часті запитання
ScrollView є архітектурно допустимою виключно тоді, коли вибірка є гарантовано обмеженою (до 5–10 елементів), контент має гетерогенну природу (наприклад, екран налаштувань профілю або складна форма реєстрації) і не передбачає динамічного росту. Як тільки сутності представляють однотипну колекцію, що надходить з бекенду або бази даних, інженерним стандартом є застосування віртуалізованого списку (FlatList або FlashList).flex: 1 або висоти, заданої у фізичних одиницях (height: 300), висота списку схлопується до нуля або він намагається розтягнути полотно на всю висоту контенту без активації внутрішнього нативного скролу.FlatList оптимізує рендеринг за рахунок неглибокого порівняння посилання на масив data. Якщо візуальний стан рядка залежить від зовнішніх змінних (наприклад, ID виділеного елемента selectedId, активна мова застосунку, стан глобальної теми), зміна цих змінних не змінює посилання на data. Передача таких залежностей у проп extraData={selectedId} інвалідує внутрішній кеш віртуалізатора і форсує перерендер видимих комірок.FlatList із React.memo та getItemLayout більш ніж достатньо. Бібліотеку @shopify/flash-list варто інтегрувати в проєкт, коли екран містить важкі динамічні стрічки (соціальні мережі, маркетплейси), де FlatList демонструє появу білих порожнеч при швидкому скролі через витрати на циклічне створення та видалення нативних представлень.::react-native-preview працює на базі react-native-web у браузерному середовищі, куди підключено лише стандартний набір компонентів ядра React Native. Бібліотека @shopify/flash-list містить платформозалежний C++/нативний код і призначена для запуску на реальних мобільних пристроях або в симуляторах iOS/Android (включно з клієнтом Expo Go).SectionList формує єдине віртуалізоване вікно перегляду для всіх секцій одночасно, спільно управляючи пам'яттю та чергою рендерингу. Розміщення кількох вертикальних FlatList один під одним усередині загального ScrollView призводить до конфліктів скролу (Nested Scroll Conflicts), втрати віртуалізації та надлишкового споживання ресурсів. Водночас розміщення горизонтального списку в ListHeaderComponent вертикального списку є стандартним і безпечним патерном.<RefreshControl /> передано безпосередньо у проп refreshControl списку, а не обгорнуто в зовнішній View. Також переконайтеся, що стан refreshing змінюється на true у функції onRefresh та коректно скидається у false після завершення асинхронної операції.Що далі
Далі — форми: TextInput, клавіатура, валідація (RHF + Zod), екран створення поїздки. Списки вже вміють показувати результат; форми навчать додавати дані в ці списки.
FlatList · SectionList · VirtualizedList · RefreshControl · FlashList · Nomad
StyleSheet і теми (light / dark)
StyleSheet.create, масиви стилів, семантичні токени кольорів, useColorScheme і перемикач світлої/темної теми в Nomad
Форми, ввід і валідація
Усі основні контроли форми в React Native — TextInput, Pressable, Button, Switch, Checkbox, Slider, Picker, date picker; клавіатура; React Hook Form і Zod; реєстрація на подію та створення поїздки в Nomad