Списки та віртуалізація
Списки та віртуалізація
Навіщо ця стаття
Уявіть стрічку поїздок: не три картки «для демо», а сотні записів за роки — з обкладинками, датами, короткими описами. Користувач гортає пальцем униз у метро. Якщо застосунок одразу створює усі картки в пам’яті (як array.map усередині ScrollView), телефон починає «заїкатися»: довге перше відкриття, ривки при скролі, нагрів, інколи навіть закриття застосунку системою.
У статті про базові компоненти ми вже розвели ролі: ScrollView — для невеликого прокручуваного контенту (форма, стаття, 3–5 карток). Офіційна документація React Native прямо радить: для довгих однотипних списків брати FlatList (або сильніший аналог), а не мапити все в ScrollView.
Ця стаття — про те, як малювати довгі списки так, щоб телефон тримав лише «вікно» рядків біля екрана. Це і є віртуалізація (virtualization).
Після статті ви зможете:
- Пояснити різницю між
ScrollView+mapі віртуалізованим списком. - Зібрати
FlatListзdata,renderItem,keyExtractor. - Показати порожній стан (
ListEmptyComponent), шапку/підвал списку, pull-to-refresh. - Зрозуміти UI пагінації (
onEndReached) — без справжнього API (мережа буде пізніше). - Згрупувати дані в
SectionList(наприклад, книги за жанром). - Знати, навіщо
FlashListі коли його ставити замістьFlatList. - У Nomad — віртуалізована стрічка поїздок + горизонтальний список місць.
::react-native-preview (react-native-web). FlatList / SectionList / RefreshControl у прев’ю працюють. FlashList у iframe немає (окремий пакет) — його розбираємо в тексті й підключаємо в Nomad / міні-проєкті на пристрої. Скрол у браузері зручніший мишею/трекпадом; «рідний» feel — у Expo Go.Місток з web React
| У браузері | У React Native | Коментар |
|---|---|---|
<ul>{items.map(…)}</ul> на сторінці | ScrollView + map | Усе в DOM / у нативному дереві одразу |
window + document scroll | ScrollView | Одна довга «сторінка» контенту |
react-window, react-virtualized, TanStack Virtual | FlatList / SectionList / FlashList | Віртуалізація: малюємо лише visible (+ buffer) |
| Infinite scroll (IntersectionObserver) | onEndReached | Підвантаження, коли близько до кінця |
| Pull-to-refresh (мобільні веб-патерни) | RefreshControl | «Потягнути вниз — оновити» |
{trips.map(t => <TripCard … />)} всередині ScrollView і вважати, що «це ж той самий React». Для 3 карток — нормально. Для 200 — ви створюєте 200 нативних view з картинками до першого кадру. На слабкому Android це відчувається одразу.Що ламається без віртуалізації
Життєва сцена
Ви відкрили «Мандрівник». На екрані видно дві картки поїздок. Решта — нижче, за межами екрана. Питання: чи потрібно телефону зараз тримати в пам’яті ще 198 карток з Image?
Якщо відповідь «так, бо ми зробили map» — ви платите CPU і RAM за те, чого користувач не бачить.
ScrollView + map — що відбувається
1. Завантаження екрана
React будує усі дочірні елементи масиву. Кожна картка — окремий піддерево (Pressable, Image, тексти).
2. Layout
Нативний шар рахує висоту всього вмісту (навіть за екраном), щоб знати, наскільки довгий скрол.
3. Скрол
Пам’ять уже зайнята; під час скролу ще йдуть оновлення зображень, тіні, перерахунки — телефон «пихтить».
4. Що бачить користувач
Ривки, білі спалахи, довгий cold start. Людина не каже «віртуалізація погана» — каже «додаток гальмує».
Віртуалізація простими словами
Віртуалізація (virtualization) — техніка, коли UI не створює одночасно всі елементи довгого списку, а малює лише ті, що потрібні для поточного положення скролу (плюс невеликий буфер поза екраном, щоб скрол був плавним).
Аналогія з вебом: якщо ви колись підключали react-window до таблиці на 10 000 рядків — ідея та сама. У React Native «дефолтний» інструмент для цього — FlatList (під капотом — VirtualizedList).
data, для якої зараз існують нативні view. Рухається разом зі скролом.id з даних, не індекс масиву). React і список мають розуміти «це той самий trip, лише зсунувся», а не «зовсім новий елемент».FlatList — базовий віртуалізований список
FlatList — компонент React Native для відображення довгого прокручуваного списку однотипних елементів з віртуалізацією. Ви передаєте масив і функцію «як намалювати один елемент»; список сам вирішує, які елементи зараз живуть у дереві.
Мінімальний каркас:
import { FlatList, Text, View } from 'react-native';
type Item = { id: string; title: string };
const DATA: Item[] = [
{ id: '1', title: 'Перший' },
{ id: '2', title: 'Другий' },
{ id: '3', title: 'Третій' },
];
export function SimpleList() {
return (
<FlatList
data={DATA}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View>
<Text>{item.title}</Text>
</View>
)}
/>
);
}
Анатомія props
data змінюється за посиланням і вмістом — список оновлюється. Для «виділили рядок» див. extraData.useCallback) або виносьте компонент рядка назовні.item.key або item.id. Краще явнийkeyExtractor, якщо поле інше.data порожній (або довжина 0). Порожній стан — частина UX, не «білий екран».marginBottom на кожній картці з «у останньої нуль».ScrollView: не плутати зі style самої «рамки» списку.flex: 1, щоб зайняти місце між шапкою екрана і sticky-кнопкою.item (наприклад, selectedId), передайте цей стан у extraData, інакше FlatList може не перемалювати рядки (він оптимізує оновлення).refreshing / onRefresh, або повний <RefreshControl /> через refreshControl={…}.threshold — частка видимої довжини (наприклад 0.3 ≈ «за 30% екрана до кінця»). Для «підвантажити ще сторінку».Демо: простий 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 → область списку). Якщо батько «висота за контентом», віртуалізація ламається або скрол не з’являється.keyExtractor: чому не індекс
// Погано для динамічних списків
keyExtractor={(_, index) => String(index)}
// Добре
keyExtractor={(item) => item.id}
Що бачить користувач при «поганих» ключах: після видалення/вставки рядка «стрибають» стани (розкрита картка, локальний useState у рядку), картинки «липнуть» не до тих об’єктів, анімації дивні.
Правило: ключ = стабільна бізнес-ідентичність (trip.id), не позиція в масиві.
key={…} вручну на корені того, що повертає renderItem «замість» keyExtractor. Список сам керує ключами через keyExtractor. Дублювання плутає recycle (особливо у FlashList).Порожній стан, шапка, розділювачі
Порожній список — не баг. Це нормальний екран: «Поки немає поїздок» + підказка, що робити далі.
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,
},
});
contentContainerStyle + empty. Щоб порожній стан вирівняти по центру вертикально, часто ставлять contentContainerStyle={{ flexGrow: 1 }} (коли data.length === 0). Інакше empty «прилипає» до верху скролу.Pull-to-refresh (RefreshControl)
Pull-to-refresh — жест «потягнути список вниз і відпустити», після якого застосунок оновлює дані. На iOS звичний спінер зверху; на Android — свій Material-індикатор.
У React Native це компонент RefreshControl, який передають у refreshControl у ScrollView / FlatList / SectionList (вертикальний скрол).
true — крутиться індикатор, навіть якщо жест уже відпущено.refreshing = true, вантажите дані, потім false.tintColor. Android: colors={[…]} — колір індикатора під ваш primary.Демо: оновлення списку
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
Пагінація — підвантаження даних порціями (сторінками), а не «усі 10 000 з сервера одразу».
На мобільному UX часто:
- Перша порція (наприклад, 20 елементів).
- Користувач доскролив майже до кінця.
- Показуємо footer «Завантаження…».
- Дописуємо наступну порцію в
data. - Якщо сервер сказав «більше немає» — footer зникає / «Це все».
onEndReached викликається, коли скрол наближається до кінця списку. onEndReachedThreshold — наскільки рано (частка visible length).
data колбек може спрацювати знову. Захист: прапорець loadingMore, перевірка hasMore, не стартувати другий fetch, поки перший не закінчився. У цій статті — mock; з RTK Query / TanStack Query захист ляже на шар даних.Демо: «ще 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' },
});
Стан помилки списку
Порожній масив після успішного відповіді «у вас 0 поїздок» і порожній екран після мережевої помилки — різні речі. Не зводьте обидва до одного ListEmptyComponent без розрізнення.
Типова модель UI (ще до RTK Query):
| Стан | Що на екрані |
|---|---|
loading (перший раз) | скелетон / спінер по центру, не empty |
success + data.length === 0 | ListEmptyComponent («Створіть першу поїздку») |
success + є дані | FlatList з рядками |
error | банер / повний error-state з «Спробувати знову» (часто замість списку або над ним) |
refreshing | список лишається, зверху RefreshControl |
SectionList — секції з заголовками
SectionList — віртуалізований список, де дані розбиті на секції (sections). У кожної секції є заголовок (title / renderSectionHeader) і свій масив data.
Аналогія: контакти в телефоні, згруповані за літерою; меню ресторану за категоріями; каталог книг за жанром.
Форма даних:
type Section = {
title: string;
data: Book[];
};
const sections: Section[] = [
{ title: 'Фантастика', data: [/* … */] },
{ title: 'Детектив', data: [/* … */] },
];
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <BookRow book={item} />}
renderSectionHeader={({ section: { title } }) => (
<Text style={styles.sectionHeader}>{title}</Text>
)}
stickySectionHeadersEnabled
/>
stickySectionHeadersEnabled за замовчуванням true: заголовок секції «липне» зверху, доки його не витіснить наступний. Звичний патерн системних списків Apple.false) — на Android це не такий універсальний системний жест. Можна ввімкнути явно, якщо дизайн цього хоче; перевірте на реальному пристрої, що заголовки не перекривають контент дивно.Демо: секції
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,
},
});
Продуктивність рядка: memo, стабільні колбеки
Віртуалізація не скасовує дорогий рядок. Якщо кожен TripCard тягне величезне зображення без розмірів, робить важкі обчислення в render і слухає зайвий контекст — список усе одно гальмуватиме.
Практичні звички (достатньо «на зараз»; глибокий profiling — окрема стаття):
React.memoна компоненті рядка — не перемальовувати, якщоtripіonPressті самі за посиланням.useCallbackдляrenderItem/onPress— щоб memo мав шанс спрацювати.- Не створювати нову функцію-обгортку в JSX без потреби в кожному рядку, якщо можна передати
itemі стабільний handler. - Картинки: фіксована висота обкладинки, розумний розмір URI (
w=800), пізніше — кеш /expo-image. getItemLayout(опційно), якщо усі рядки однакової висоти — список швидше рахує offsets без вимірювання. Для карток різної висоти часто не використовують.
const renderItem = useCallback(
({ item }: { item: Trip }) => (
<TripCard trip={item} onPress={handleOpenTrip} />
),
[handleOpenTrip],
);
extraData. Виділення рядка (selectedId) живе позаitem. Без extraData={selectedId} FlatList може вирішити, що data не змінився, і не оновити підсвітку. Передавайте все «зовнішнє», від чого залежить вигляд рядка.FlashList — коли FlatList уже «не тягне»
FlashList (@shopify/flash-list) — список від Shopify з більш агресивним recycling рядків і зазвичай кращою продуктивністю на довгих/складних списках, ніж у вбудованого FlatList. API навмисно близький до FlatList: часто міграція — це зміна імпорту й кілька props.
Навіщо він з’явився
FlatList достатній для багатьох екранів. Але на стрічках з картинками, змінною висотою, складними картками з’являються «порожні дірки» під час швидкого скролу, jank, зайві mount/unmount. FlashList перевикористовує view-рядків (ідея близька до Android RecyclerView).
Встановлення (Expo)
npx expo install @shopify/flash-list
# або
npm install @shopify/flash-list
Сумісний з Expo Go (окремий native config plugin для базового сценарію зазвичай не потрібен).
Міграція з FlatList (ідея)
// було
import { FlatList } from 'react-native';
<FlatList data={…} renderItem={…} keyExtractor={…} />
// стало
import { FlashList } from '@shopify/flash-list';
<FlashList data={…} renderItem={…} keyExtractor={…} />
estimatedItemSize — приблизну висоту (або ширину) рядка в dp. Без оцінки layout «стрибав». Брали середню висоту картки (наприклад, 220).estimatedItemSize / estimatedListSize прибрали: розміри рахуються сучасніше. Дивіться версію в package.json і migration notes для вашої major-версії.Важливі відмінності від FlatList
| Тема | Що знати |
|---|---|
| Recycling | Рядок може одержати нові props без повного unmount — локальний useState у рядку «переїде» на інший item, якщо не синхронізувати зі item.id |
key у дереві item | Не розставляйте зайві key всередині item tree «по-старому» — суперечить recycle |
| Nested lists | Горизонтальний список у header — ок; важкі вкладені вертикальні списки все одно болючі для UX |
| Прев’ю на сайті курсу | У ::react-native-preview пакета немає — перевірка на пристрої / симуляторі |
const [open, setOpen] = useState(false) і користувач розкрив акордеон, після recycle цей же компонент-інстанс може показати іншийitem уже з open === true. Або ключуйте стан словником id → state зверху, або скидайте стан у useEffect від item.id.FlashList. Горизонтальні «останні місця» — FlatList horizontal (короткий список, API з коробки). Обидва підходи законні; головне — не мапити 200 карток у ScrollView.Антипатерни
| Антипатерн | Чому погано | Що замість |
|---|---|---|
ScrollView + data.map на 100+ | Усе в пам’яті одразу | FlatList / FlashList |
keyExtractor = index | Плутанина ідентичності | item.id |
Новий renderItem={() => …} без memo-стратегії | Зайві ререндери | useCallback + memo на рядку |
Список без flex: 1 | Немає bounded height | Ланцюг flex від Screen |
| Empty і error — один екран | Користувач не розуміє причину | Окремі стани |
onEndReached без loadingMore | Подвійні запити | Прапорець / захист у data-шарі |
| Ігнорувати recycle у FlashList | «Липкий» UI-стан рядка | Стан від item.id |
Міні-проєкт: «Каталог книг»
Що має вийти
Окремий маленький Expo-екран (можна тимчасовий проєкт npx create-expo-app book-catalog або окремий файл у пісочниці):
- 200+ mock-книг.
- Групування в секції за жанром (
SectionList). - Pull-to-refresh (перемішує / оновлює «рейтинг» mock).
- Порожній стан (кнопка «Очистити полицю» / «Заповнити знову»).
- (Опційно) footer пагінації або одразу всі 200 у
sections— на вибір; головне — віртуалізація, неScrollView.
Модель даних
type Book = {
id: string;
title: string;
author: string;
genre: string;
year: number;
};
type BookSection = {
title: string;
data: Book[];
};
Кроки
1. Згенерувати mock
Масив жанрів: ['Фантастика', 'Детектив', 'Подорожі', 'Історія', 'Поезія'].
Цикл на 220 елементів: id, назва Книга ${i}, автор, жанр по модулю, рік.
2. Згрупувати в sections
reduce / Map за genre → масив { title, data }, відсортувати title.
3. SectionList
renderItem — рядок (назва, автор, рік).renderSectionHeader — жанр.stickySectionHeadersEnabled — увімкнути й глянути на iOS/Android.
4. Refresh
RefreshControl: під час refresh — setSeed(s => s+1) і перезібрати mock (інші «рейтинги» в підзаголовку).
5. Empty
Кнопка «Очистити» → sections = [] → ListEmptyComponent.
«Заповнити» → знову generate.
6. Перевірка
Скрол має бути плавним; у React DevTools / логіці ви не монтуєте 220 важких дерев одночасно як у ScrollView (перевірка «на око»: швидкий fling без багатосекундного freeze на старті).
Повний орієнтир коду (згорнуто)
Код повний — розгорніть, щоб скопіювати в App.tsx пісочниці. У прев’ю нижче — стисліша версія для браузера.
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: віртуалізовані поїздки та місця
Навіщо користувачу
Стрічка росте. Потрібна віртуалізація, pull-to-refresh і горизонтальні «останні місця», без втрати теми й sticky CTA з попередніх статей.
Нитка проєкту
Уже є (з попередніх статей — не викидаємо):
- Тема (08):
ThemeProvider, чіпи Система/Світла/Темна в sticky header. - Shell (07): header + середня зона + sticky «Нова поїздка».
- Картки (06):
TripCard+mockTrips(було 3; список черезScrollView+map).
Додаємо в цій статті:
- Залежність
@shopify/flash-list; стрічка поїздок —FlashList(неScrollView+map). - Більше mock-поїздок; поле
regionнаTrip. - Фіча місць:
Place,mockPlaces,PlaceChip, horizontalFlatListуListHeaderComponent. RefreshControl,ListEmptyComponent;TripCard/PlaceChipуmemo.- Не прибираємо чіпи теми з 08 — вони лишаються в sticky header.
Повний знімок проєкту після цієї статті
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.
Часті запитання
flex: 1, або список у вкладеному контейнері без висоти. Перевірте ланцюг Screen → listWrap → FlashList/FlatList.item (виділення, multi-select). Інакше FlatList може пропустити оновлення рядків.FlatList достатній. FlashList — практичний default для «важких» стрічок у продакшені; у Nomad ставимо його рано, щоб звикнути до API.react-native-preview підключає лише react + react-native. Сторонні пакети туди не входять. FlashList перевіряйте в Expo Go / симуляторі.SectionList — одна віртуалізація на всі секції. Кілька незалежних FlatList на одному екрані (вертикально) зазвичай гірший UX (вкладений скрол). Горизонтальний список у header вертикального — нормальний патерн.refreshControl переданий саме в список, а не в сусідній View.Що далі
Далі — форми: 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