Форми, ввід і валідація
Вступ: особливості форм та вводу на мобільних пристроях
Форма у мобільному застосунку — це головний спосіб взаємодії користувача з додатком. На відміну від веб-інтерфейсів, де користувач оперує повнорозмірною фізичною клавіатурою, високоточним курсором миші та великою площею дисплея, мобільні форми функціонують в умовах обмеженого простору та сенсорного вводу.
Мобільне введення даних підпорядковується базовим принципам людино-машинної взаємодії (Human-Computer Interaction, HCI):
- Закон Фіттса (Fitts's Law): час, необхідний для точного позиціонування пальця на елементі керування, є функцією відстані до цілі та її геометричного розміру (Завантаження...). Інтерфейсні елементи на сенсорному екрані повинні мати мінімальну фізичну площу детекції дотику (Touch Target Size) не менше 44×44 pt на iOS та 48×48 dp на Android.
- Перекриття клавіатурою (Keyboard Occlusion): поява віртуальної клавіатури (Soft Keyboard) миттєво скорочує корисну площу вікна перегляду на 40–55%, фізично перекриваючи елементи введення та кнопки завершення дії.
- Когнітивне навантаження та ціна помилки: відсутність тактильного відгуку фізичних клавіш суттєво підвищує ймовірність помилок друку (typos). Ефективна форма мінімізує відкритий текстовий ввід на користь вибору з готових списків, перемикачів і вибору дати.
Навчальні цілі розділу:
- Розібрати основні нативні контроли введення React Native (
TextInput,Switch,Checkbox,Slider,Picker,DateTimePicker) та навчитися обирати правильний елемент під конкретний тип даних. - Зрозуміти архітектурні відмінності між подієвою моделлю Web DOM та нативними віджетами операційної системи (iOS
UITextField/ AndroidEditText). - Зрозуміти, як працюють контрольовані компоненти (Controlled Components): двостороннє зв'язування, життєвий цикл синхронізації та усунення стану «залипання» (Input Freezing).
- Налаштувати надійне запобігання перекриттю форми клавіатурою за допомогою зв'язки
KeyboardAvoidingView+ScrollView+keyboardShouldPersistTaps="handled". - Побудувати надійну систему валідації на базі React Hook Form та схем Zod через адаптер
@hookform/resolvers/zodз підтримкою режимівmode: 'onSubmit'таreValidateMode: 'onChange'.
Огляд та класифікація контролів введення
Форми у мобільних додатках складаються з різних елементів керування, кожен з яких оптимізований під конкретний тип даних:
+-------------------------------------------------------------------------+
| КЛАСИФІКАЦІЯ КОНТРОЛІВ REACT NATIVE |
+-------------------------------------------------------------------------+
| Тип даних | Нативний компонент | Платформова реалізація|
+--------------------------+----------------------+-----------------------+
| Вільний текст | TextInput | UITextField / EditText|
| Бінарний перемикач | Switch | UISwitch / Switch |
| Логічна згода | Checkbox (Community) | Checkbox / Custom SVG |
| Неперервний діапазон | Slider (Community) | UISlider / SeekBar |
| Дискретний список (1..N)| Picker (Community) | UIPicker / Spinner |
| Календарна дата / час | DateTimePicker (Comm)| UIDatePicker / Dialog |
+-------------------------------------------------------------------------+
Порівняльна матриця вибору контролів
| Тип даних | Рекомендований контроль | Альтернатива (Антипатерн) | Чому це критично для UX |
|---|---|---|---|
| Довільний текст (Email, Ім'я) | TextInput | Спроба створити псевдо-інпут на Pressable | Доступ до системної клавіатури, буфера обміну, автозаповнення та предиктивного тексту. |
| Бінарне налаштування (Так/Ні) | Switch | Текстове поле «напишіть так/ні» | Миттєва зміна стану одним рухом без виклику клавіатури. |
| Юридична згода (GDPR) | Checkbox | Switch | Чекбокс асоціюється з підписанням умов договору; вимагає явної фіксації згоди. |
| Орієнтовне число (1–14 днів) | Slider | Вільний числовий TextInput | Запобігає введенню від'ємних чисел, літер та дробових значень; наочно візуалізує масштаб. |
| Фіксований набір (3–10 опцій) | Picker | Текстове поле для назви регіону | Повністю виключає одруківки, різні регістри та невалідні значення в базі даних. |
| Календарна дата | DateTimePicker | Текстове поле «ДД.ММ.РРРР» | Захищає від неіснуючих дат (наприклад, 31 лютого), вирішує конфлікти локальних форматів. |
Архітектурне порівняння: Web React проти React Native
Розробники з досвідом у веб-розробці часто припускаються системних помилок, переносячи браузерні ментальні моделі в мобільне середовище.
ВЕБ-АРХІТЕКТУРА (DOM) REACT NATIVE (NATIVE VIEWS)
+---------------------------------+ +---------------------------------------+
| <form onSubmit={...}> | | <View> (Контейнер без форми) |
| <input type="email" /> | vs | <TextInput keyboardType="email" /> |
| <button type="submit" /> | | <Pressable onPress={handleSubmit}> |
| </form> | | </View> |
+---------------------------------+ +---------------------------------------+
| • Події спливають (Bubbling) | | • Немає спливання подій |
| • Синтетичний event.target.value| | • onChangeText передає чистий рядок |
| • Неконтрольовані ref у RHF | | • RHF вимагає Controller |
+---------------------------------+ +---------------------------------------+
1. Текстовий ввід та модель подій
- Web: елементи
<input>та<textarea>породжують синтетичні подіїonChange, де нове значення витягується черезevent.target.value. Спеціалізація поля задається єдиним атрибутомtype(text,password,email,number), який одночасно керує валідацією, відображенням та поведінкою браузера. - React Native: компонент
TextInputне є DOM-вузлом. Це декларативна обгортка над нативнимUITextFieldв iOS абоEditTextв Android. КолбекonChangeTextодразу передає готовий нормалізований рядок(text: string) => void. Атрибутtypeвідсутній — поведінка налаштовується окремими параметрами:keyboardType(тип клавіатури),secureTextEntry(маскування),multiline(багаторядковість),autoCapitalizeтаautoCorrect.
2. Відсутність контейнера <form> та механізму Submit
- Web: тег
<form>реалізує вбудовану подіюsubmit, яка автоматично перехоплює натискання клавіші Enter на будь-якому вкладеному інпуті та ініціює валідацію або надсилання даних. - React Native: концепція HTML-форми відсутня. Немає автоматичного спливання подій (Event Bubbling) до спільного батьківського контейнера. Запуск валідації та відправка форми завжди є явною процедурою: виклик функції
handleSubmitу властивостіonPressкастомної кнопкиPressable.
3. Екосистема компонентів: Core проти Community Packages
У браузері всі базові контроли (<select>, <input type="checkbox">, <input type="range">, <input type="date">) вбудовані в HTML5-стандарт.
У React Native базове ядро навмисно полегшене (Lean Core initiative):
- У ядрі
react-nativeзбережено лишеTextInputтаSwitch. - Контроли
Picker,Slider,Checkbox,DateTimePickerвинесені в офіційно підтримувані пакети спільноти (Community Packages):@react-native-picker/picker,@react-native-community/slider,@react-native-community/datetimepicker,@react-native-community/checkbox. - Встановлення таких модулів здійснюється строго через менеджер пакетів Expo:
npx expo install <package-name>, що гарантує сумісність нативних бінарних залежностей із поточною версією Expo SDK.
4. Прив'язка полів у React Hook Form: register проти Controller
У веб-додатках бібліотека React Hook Form спирається на неконтрольовані DOM-інпути через прямий доступ до рефів: <input {...register('email')} />.
У React Native прямий доступ до DOM-рефів неможливий через ізоляцію нативних шарів. Єдиним архітектурно коректним способом інтеграції є компонент Controller, який транслює внутрішній стан RHF у явні пропси контрольованого компонента (value, onChange, onBlur).
Теорія контрольованих значень (Controlled Components)
У декларативній моделі React Native базовим патерном роботи з формами є контрольований компонент (Controlled Component).
Контрольованим називається компонент, відображення якого повністю визначається станом React (Single Source of Truth), а будь-яка спроба користувача змінити цей стан ініціює подію зворотного виклику:
Універсальна матриця зв'язування контролів
Кожен контроль у React Native реалізує цей двосторонній контракт через відповідну пару пропсів:
| Контрол | Тип значення (State Type) | Проп передачі значення (Value Prop) | Проп підписки на зміну (Change Event Prop) |
|---|---|---|---|
TextInput | string | value={text} | onChangeText={(text) => setText(text)} |
Switch | boolean | value={isEnabled} | onValueChange={(bool) => setIsEnabled(bool)} |
Checkbox | boolean | value={isChecked} | onValueChange={(bool) => setIsChecked(bool)} |
Slider | number | value={num} | onValueChange={(num) => setNum(num)} |
Picker | T (string | number) | selectedValue={val} | onValueChange={(val) => setVal(val)} |
DateTimePicker | Date | value={date} | onChange={(event, date) => setDate(date)} |
Наслідки порушення контракту контрольованості:
Якщо розробник передає проп значення (value), але забуває оновити стан у колбеці (onChangeText / onValueChange), інтерфейс впадає у стан «залипання» (Input Freezing). Нативний віджет отримує новий символ від клавіатури, повідомляє React про зміну, але під час фази узгодження (Reconciliation) React примусово відновлює старе значення value зі стану. Користувач сприймає це як фатальний баг або апаратне зависання екрана.
TextInput: поглиблений аналіз нативного текстового поля
Компонент TextInput є прямим містком до нативних компонентів текстового введення операційної системи:
- iOS: інкапсулює нативний клас
UITextField(для однорядкового вводу) абоUITextView(для багаторядкового режимуmultiline={true}). - Android: транслюється в нативний віджет
android.widget.EditText, інтегрований із платформовим менеджером редакторів методу введення (Input Method Editor, IME).
На відміну від статичних графічних елементів, TextInput підтримує складний системний стан: поточну позицію каретки (cursor position), виділений діапазон символів (selection range), чергу асинхронних подій автокорекції, буфер проміжних символів клавіатури (для ієрогліфічного чи предиктивного вводу) та активне віконне з'єднання з процесом клавіатури.
Життєвий цикл контрольованого введення
import { useState } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
export function NameField() {
const [name, setName] = useState('');
return (
<View style={styles.container}>
<Text style={styles.label}>Повне ім'я</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Олена Ковальчук"
autoCapitalize="words"
autoCorrect={false}
style={styles.input}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 6 },
label: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase' },
input: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
minHeight: 48,
},
});
Покроковий механізм обробки вводу:
- Користувач натискає клавішу на системній клавіатурі.
- Нативний віджет (
UITextField/EditText) фіксує символ та ініціює подію через міст/JSI до JavaScript-потоку. - Викликається обробник
onChangeText(newString)із повним новим рядком. - Функція
setNameпланує оновлення стану React. - Фаза Reconciliation обчислює новий VDOM і передає оновлене значення
valueназад у нативний віджет для синхронізації відображення.
Повний огляд параметрів (Props) TextInput
TextInput у строго контрольований режим (Controlled Mode). Компонент гарантує відповідність тексту на екрані значенню у вашому стані React / React Hook Form. Якщо стан не оновлюється під час виклику onChangeText, компонент примусово відновлює попереднє значення зі стану.onChange (який повертає складний об'єкт SyntheticEvent), onChangeText одразу передає десеріалізований нормалізований рядок text. Викликається на кожен введений, видалений або вставлений з буфера символ.value === ''). Зникає в момент введення першого символу. Обов'язково налаштовуйте placeholderTextColor відповідно до активної кольорової схеми (Light/Dark), щоб уникнути втрати контрастності.false поле переходить у режим «лише для читання» (Read-Only): текст залишається видимим, але тапи ігноруються, а клавіатура не з'являється. Застосовується під час виконання асинхронного submit-запиту для блокування повторного редагування.UITextView. На платформі Android для забезпечення правильного вирівнювання тексту за верхнім краєм контейнера обов'язково додавайте властивість textAlignVertical: 'top' у стилях..max(N)).multiline={false}).default— стандартна алфавітно-цифрова клавіатура.email-address— розкладка з оптимізованим доступом до символу@, крапки та доменних зон.numeric/number-pad— цифровий блок для введення чисел, PIN-кодів та сум.phone-pad— телефонна клавіатура з символами+,*,#.url— оптимізація для введення веб-посилань (/,.com).decimal-pad— цифрова клавіатура з підтримкою десяткового роздільника (крапка/кома відповідно до регіональних налаштувань ОС).
none— обов'язковий вибір дляemail, паролів, URL та логінів.sentences— заголовні літери на початку кожного речення (стандарт для нотаток).words— заголовні літери для кожного слова (імена людей, назви міст).characters— усі літери вводяться у верхньому регістрі (серійні номери, промокоди, IBAN).
autoCorrect={false}.next, done, go, search, send). Допомагає користувачеві орієнтуватися в ланцюжку полів.nextInputRef.current?.focus().blurOnSubmit={false} у поєднанні з returnKeyType="next".mode: 'onBlur' або mode: 'onTouched'.Програмне управління фокусом та ланцюжки введення
На мобільних пристроях перехід між полями форми здійснюється за допомогою імперативних рефів useRef<TextInput>(null). Це усуває необхідність закривати клавіатуру та вручну тапати по наступному інпуту:
import { useRef } from 'react';
import { TextInput, View } from 'react-native';
export function ChainedInputsForm() {
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
return (
<View>
<TextInput
placeholder="Ім'я користувача"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => emailRef.current?.focus()}
/>
<TextInput
ref={emailRef}
placeholder="Електронна пошта"
keyboardType="email-address"
autoCapitalize="none"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
/>
<TextInput
ref={passwordRef}
placeholder="Пароль"
secureTextEntry
returnKeyType="done"
onSubmitEditing={() => console.log('Submit')}
/>
</View>
);
}
Демо: різні типи клавіатур
Loading…
react-native-web · not a real device
import { useState } from 'react';
import {
ScrollView,
StyleSheet,
Text,
TextInput,
useColorScheme,
} from 'react-native';
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';
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [note, setNote] = useState('');
return (
<ScrollView
style={{ flex: 1, backgroundColor: bg }}
contentContainerStyle={{ padding: 16, gap: 14 }}
keyboardShouldPersistTaps="handled"
>
<Text style={{ color: text, fontWeight: '800', fontSize: 22 }}>Типи вводу</Text>
<Text style={{ color: muted, fontSize: 13 }}>
На пристрої змінюється розкладка клавіатури. У прев’ю — ті самі props.
</Text>
<Text style={[styles.label, { color: muted }]}>Email</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
placeholderTextColor={muted}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
style={[styles.input, { backgroundColor: surface, borderColor: border, color: text }]}
/>
<Text style={[styles.label, { color: muted }]}>Телефон</Text>
<TextInput
value={phone}
onChangeText={setPhone}
placeholder="+380…"
placeholderTextColor={muted}
keyboardType="phone-pad"
style={[styles.input, { backgroundColor: surface, borderColor: border, color: text }]}
/>
<Text style={[styles.label, { color: muted }]}>Нотатка (multiline)</Text>
<TextInput
value={note}
onChangeText={setNote}
placeholder="Кілька рядків…"
placeholderTextColor={muted}
multiline
textAlignVertical="top"
style={[
styles.input,
styles.multi,
{ backgroundColor: surface, borderColor: border, color: text },
]}
/>
</ScrollView>
);
}
const styles = StyleSheet.create({
label: { fontSize: 12, fontWeight: '700', textTransform: 'uppercase' },
input: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
minHeight: 48,
},
multi: { minHeight: 100 },
});
Pressable та Button: механіка ініціації дій та стани взаємодії
Поля форми відповідають за збір та десеріалізацію даних, тоді як кнопки фіксують намір користувача (User Intent): підтвердження, скидання, перехід до наступного кроку або скасування.
Архітектура Pressable: скінченний автомат обробки дотиків
Базовий компонент Pressable є низькорівневим інструментом відстеження жестової взаємодії, побудованим за принципом скінченного автомата станів (Finite State Machine):
+---------------------------------------------+
| IDLE |
+---------------------------------------------+
| ^
onPressIn onPressOut / Cancel
v |
+---------------------------------------------+
| PRESSED (style: ({ pressed }) => [...]) |
+---------------------------------------------+
|
Touch Released (within hitSlop boundary)
v
+---------------------------------------------+
| EXECUTE: onPress (Submit) |
+---------------------------------------------+
Ключові можливості та ергономіка Pressable:
- Динамічна функція стилів (
style={({ pressed }) => [...]}): Надає прямий доступ до стану натискання в момент контакту пальця з екраном. Це дозволяє реалізувати миттєвий візуальний зворотний зв'язок (зміна непрозоростіopacity: 0.85або трансформація масштабуtransform: [{ scale: 0.98 }]) до запуску важких асинхронних операцій. - Закон Фіттса та
hitSlop: ВластивістьhitSlop: Insets({ top: 12, bottom: 12, left: 12, right: 12 }) розширює фізичну площу детекції дотику за межі візуальних рамок елемента без порушення геометричного розташування сусідніх компонентів у Flexbox. Це гарантує відповідність нормам доступності (мінімум 44×44 pt). - Захист від подвійного сабміту (Idempotency & Race Conditions):
При асинхронній відправці форми обов'язково використовується стан блокування
disabled={isSubmitting}:
import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native';
interface SubmitButtonProps {
onPress: () => void;
isSubmitting: boolean;
title: string;
}
export function SubmitButton({ onPress, isSubmitting, title }: SubmitButtonProps) {
return (
<Pressable
onPress={onPress}
disabled={isSubmitting}
style={({ pressed }) => [
styles.button,
pressed && !isSubmitting && styles.buttonPressed,
isSubmitting && styles.buttonDisabled,
]}
>
{isSubmitting ? (
<ActivityIndicator color="#FFFFFF" />
) : (
<Text style={styles.buttonText}>{title}</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#2563EB',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
minHeight: 48,
},
buttonPressed: { opacity: 0.85, transform: [{ scale: 0.99 }] },
buttonDisabled: { backgroundColor: '#94A3B8' },
buttonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '700' },
});
Системний Button проти кастомного Pressable
| Характеристика | Системний Button (react-native) | Кастомний Pressable (Дизайн-система) |
|---|---|---|
| Рендеринг | Нативний віджет платформи (iOS Button / Android Material Button) | Повна композиція React Native елементів |
| Стилізація | Обмежена (лише колір тексту color на iOS або фон на Android) | Необмежена (градієнти, тіні, скруглення, анімації) |
| Вкладений контент | Тільки статичний рядок title | Будь-яке дерево компонентів (Text, Icon, ActivityIndicator) |
| Застосування | Швидке прототипування, системні діалоги | Продакшн-інтерфейси, уніфіковані дизайн-системи |
Loading…
react-native-web · not a real device
import { useState } from 'react';
import { Pressable, StyleSheet, Text, View, useColorScheme } from 'react-native';
export default function App() {
const dark = useColorScheme() === 'dark';
const bg = dark ? '#000' : '#F8FAFC';
const text = dark ? '#f5f5f7' : '#0f172a';
const primary = dark ? '#3b82f6' : '#2563eb';
const [n, setN] = useState(0);
return (
<View style={[styles.screen, { backgroundColor: bg }]}>
<Text style={{ color: text, fontWeight: '800', fontSize: 20 }}>Дія форми</Text>
<Text style={{ color: text, marginVertical: 12 }}>Натискань: {n}</Text>
<Pressable
onPress={() => setN((x) => x + 1)}
style={({ pressed }) => [
styles.btn,
{ backgroundColor: primary, opacity: pressed ? 0.85 : 1 },
]}
>
<Text style={styles.btnText}>Надіслати</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, justifyContent: 'center' },
btn: { paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
btnText: { color: '#fff', fontWeight: '800' },
});
Switch: бінарні стани та платформні перемикачі
Компонент Switch є базовим елементом керування для бінарних значень (true / false), який транслюється в нативний віджет UISwitch на iOS та android.widget.Switch (або SwitchCompat) на платформі Android.
Ергономічна семантика: Switch проти Checkbox
| Критерій | Switch (Тумблер) | Checkbox (Прапорець) |
|---|---|---|
| UX-метафора | Фізичний тумблер живлення (увімкнено / вимкнено). | Позначення пункту у друкованому бланку або списку умов. |
| Характер дії | Миттєва дія або глобальне налаштування (темна тема, приватність, Bluetooth). | Декларативна згода або множинний вибір із переліку опцій. |
| Швидкість сприйняття | Стан зчитується за просторовим положенням повзунка (thumb) та колірною підсвіткою. | Стан зчитується за наявністю або відсутністю символу галочки всередині контуру. |
Базовий контрольований приклад
import { useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
export function PrivateToggle() {
const [isPrivate, setIsPrivate] = useState(false);
return (
<View style={styles.container}>
<View style={styles.textGroup}>
<Text style={styles.title}>Приватний доступ</Text>
<Text style={styles.subtitle}>Приховати маршрут із публічного каталогу</Text>
</View>
<Switch
value={isPrivate}
onValueChange={setIsPrivate}
trackColor={{ false: '#E2E8F0', true: '#2563EB' }}
thumbColor="#FFFFFF"
ios_backgroundColor="#E2E8F0"
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
},
textGroup: { flex: 1, paddingRight: 16 },
title: { fontSize: 16, fontWeight: '600' },
subtitle: { fontSize: 13, color: '#64748B', marginTop: 2 },
});
Повний огляд параметрів (Props) Switch
true відповідає активному положенню (ON), false — пасивному (OFF). У строго контрольованому режимі вимагає обов'язкової синхронізації зі станом React або контролером RHF.(nextValue: boolean).false) та активного (true) станів. Дозволяє узгодити системний перемикач із дизайн-токенами застосунку.value === false).Демо
Loading…
react-native-web · not a real device
import { useState } from 'react';
import { StyleSheet, Switch, Text, View, useColorScheme } from 'react-native';
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';
const primary = dark ? '#3b82f6' : '#2563eb';
const [on, setOn] = useState(false);
return (
<View style={[styles.screen, { backgroundColor: bg }]}>
<View style={[styles.row, { backgroundColor: surface, borderColor: border }]}>
<View style={{ flex: 1 }}>
<Text style={{ color: text, fontWeight: '700' }}>Приватна поїздка</Text>
<Text style={{ color: muted, marginTop: 4, fontSize: 13 }}>
{on ? 'Не показувати в спільній стрічці' : 'Видно у загальному списку'}
</Text>
</View>
<Switch
value={on}
onValueChange={setOn}
trackColor={{ false: border, true: primary }}
thumbColor="#fff"
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 16, justifyContent: 'center' },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 16,
borderRadius: 12,
borderWidth: 1,
},
});
Checkbox: прапорці підтвердження та юридична валідація
Checkbox (прапорець вибору) — спеціалізований елемент графічного інтерфейсу для фіксації підтвердження згоди з правилами (Terms of Service), вибору опцій у багатоваріантних анкетах або активації додаткових параметрів.
Оскільки у ядрі react-native відсутній кросплатформовий чекбокс, стандартом екосистеми є community-модуль @react-native-community/checkbox.
Встановлення залежності
npx expo install @react-native-community/checkbox
npm install @react-native-community/checkbox
yarn add @react-native-community/checkbox
bun add @react-native-community/checkbox
# або
pnpm add @react-native-community/checkbox
Базовий контрольований приклад
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Checkbox from '@react-native-community/checkbox';
export function TermsAgreement() {
const [accepted, setAccepted] = useState(false);
return (
<View style={styles.row}>
<Checkbox
value={accepted}
onValueChange={setAccepted}
boxType="square"
tintColors={{ true: '#2563EB', false: '#CBD5E1' }}
/>
<Text style={styles.label}>
Я погоджуюся з регламентом обробки персональних даних
</Text>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 8 },
label: { flex: 1, fontSize: 14, color: '#334155', lineHeight: 20 },
});
Повний огляд параметрів (Props) Checkbox
true відображає встановлену позначку (галочку), false — порожній контур.true) та колір контуру в пасивному стані (false).onCheckColor), внутрішня заливка активного квадрата (onFillColor) та колір рамки (onTintColor).'square').Валідація обов'язкової згоди у Zod: z.boolean() проти z.literal(true)
У формах реєстрації та транзакцій існує принципова різниця між опційним прапорцем та обов'язковою юридичною згодою:
import { z } from 'zod';
export const registrationSchema = z.object({
// 1. Опційне булеве поле: приймає true або false
subscribeToNewsletter: z.boolean(),
// 2. Обов'язкова згода: валідація проходить ТІЛЬКИ якщо значення строго дорівнює true
acceptedTerms: z.literal(true, {
errorMap: () => ({ message: 'Для продовження необхідно підтвердити згоду з правилами' }),
}),
});
При використанні z.literal(true) початковим значенням у defaultValues завжди має бути false. Це гарантує свідому дію користувача (Explicit User Action) відповідно до стандартів GDPR та безпеки інтерфейсів.
Slider: маніпуляція числовими діапазонами
Slider (повзунок) — елемент керування для вибору скалярного числового значення в замкненому інтервалі Завантаження... за допомогою неперервного або квантованого жестового перетягування.
У сучасних версіях React Native використовується офіційний пакет спільноти @react-native-community/slider.
Встановлення залежності
npx expo install @react-native-community/slider
npm install @react-native-community/slider
yarn add @react-native-community/slider
bun add @react-native-community/slider
# або
pnpm add @react-native-community/slider
Коли варто використовувати Slider
- Оптимальні сценарії: емпіричні величини, де користувач оперує відчуттям масштабу, а не точним рядковим значенням (тривалість подорожі 1–14 днів, радіус пошуку 5–50 км, рівень гучності, яскравість).
- Недоцільні сценарії: дискретні точні дані (вік, грошові суми для транзакцій, номери телефонів), де похибка позиціонування пальця створює високе когнітивне навантаження.
Базовий контрольований приклад
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Slider from '@react-native-community/slider';
export function DaysDurationSelector() {
const [days, setDays] = useState(3);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.label}>Орієнтовна тривалість</Text>
<Text style={styles.valueText}>{days} {days === 1 ? 'день' : 'днів'}</Text>
</View>
<Slider
minimumValue={1}
maximumValue={14}
step={1}
value={days}
onValueChange={setDays}
minimumTrackTintColor="#2563EB"
maximumTrackTintColor="#CBD5E1"
thumbTintColor="#2563EB"
/>
</View>
);
}
const styles = StyleSheet.create({
container: { paddingVertical: 8, gap: 8 },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
label: { fontSize: 14, fontWeight: '600', color: '#334155' },
valueText: { fontSize: 15, fontWeight: '700', color: '#2563EB' },
});
Повний огляд параметрів (Props) Slider
<Text>.step. Ідеальний для синхронізації локального UI-підпису та стану форми React Hook Form.z.number().min(min).max(max).step={1} обмежує значення цілими числами. При step={0} значення є неперервним числом із плаваючою крапкою (Float).minimumTrackTintColor), пасивна права частина (maximumTrackTintColor) та маніпулятор (thumbTintColor).Picker: вибір із фіксованого списку варіантів
Компонент Picker вирішує задачу селекції одного дискретного елемента з кінцевого переліку доступних варіантів.
У веб-розробці цьому відповідає селектор <select>. У React Native ця функціональність реалізована через незалежний модуль спільноти @react-native-picker/picker.
Встановлення залежності
npx expo install @react-native-picker/picker
npm install @react-native-picker/picker
yarn add @react-native-picker/picker
bun add @react-native-picker/picker
# або
pnpm add @react-native-picker/picker
Селекційна дилема: Picker проти вільного TextInput
Pickerдоречний: для фіксованих списків від 3 до 15 елементів (регіони країни, мовні локалі, категорії витрат, валюти, ролі доступу). Усуває помилки друку (typos) та гарантує цілісність даних на клієнті й сервері.TextInputдоречний: для нескінченних або відкритих наборів (пошукові запити, назви міст, описи).- Окремий навігаційний екран з
FlatListта пошуком: найкращий патерн, коли кількість варіантів перевищує 20 або вимагає фільтрації (наприклад, вибір аеропорту або країни).
Базовий контрольований приклад
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Picker } from '@react-native-picker/picker';
export const TRIP_REGIONS = [
'Карпати',
'Поділля',
'Полісся',
'Причорноморʼя',
'Слобожанщина',
] as const;
export type TripRegion = (typeof TRIP_REGIONS)[number];
export function RegionSelector() {
const [region, setRegion] = useState<TripRegion>('Карпати');
return (
<View style={styles.container}>
<Text style={styles.label}>Географічний регіон</Text>
<View style={styles.pickerWrapper}>
<Picker
selectedValue={region}
onValueChange={(itemValue) => setRegion(itemValue as TripRegion)}
>
{TRIP_REGIONS.map((item) => (
<Picker.Item key={item} label={item} value={item} />
))}
</Picker>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 6 },
label: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', color: '#64748B' },
pickerWrapper: {
borderWidth: 1,
borderColor: '#E2E8F0',
borderRadius: 12,
overflow: 'hidden',
},
});
Платформова дивергенція відображення
Picker за замовчуванням візуалізується як вертикальний барабан (Wheel / UIPickerView). Він займає фіксовану значну висоту (близько 216 pt). Вбудовування такого колеса безпосередньо у форму може порушувати вертикальну композицію, тому в професійних мобільних інтерфейсах (зокрема в проєкті Nomad) його часто розміщують у висувному модальному вікні (Bottom Sheet) або активують по кліку на статичний селектор-рядок.Spinner. Властивість mode дозволяє обрати між випадаючим списком mode="dropdown" (під інпутом) або блокуючим діалоговим вікном mode="dialog" (по центру екрана). Це значно компактніше за дефолтний iOS-барабан і не вимагає модальної обгортки.Синхронізація схеми Zod та TypeScript-типів (Single Source of Truth)
Щоб уникнути розбіжності між інтерфейсом та валідатором, масив констант типізується через as const:
import { z } from 'zod';
export const TRIP_REGIONS = ['Карпати', 'Поділля', 'Полісся', 'Причорноморʼя'] as const;
export const tripSchema = z.object({
region: z.enum(TRIP_REGIONS, {
errorMap: () => ({ message: 'Оберіть дійсний регіон зі списку' }),
}),
});
export type TripFormValues = z.infer<typeof tripSchema>;
DateTimePicker: темпоральний ввід та системні календарі
Введення календарних дат та часових міток через вільний TextInput є грубим антипатерном мобільного UX через конфлікт локальних форматів (американський MM/DD/YYYY проти європейського DD.MM.YYYY), складність ручної валідації високосних років і зміщення часових поясів (Timezone Offsets).
Стандартом React Native є пакет @react-native-community/datetimepicker.
Встановлення залежності
npx expo install @react-native-community/datetimepicker
npm install @react-native-community/datetimepicker
yarn add @react-native-community/datetimepicker
bun add @react-native-community/datetimepicker
# або
pnpm add @react-native-community/datetimepicker
Платформові відмінності життєвого циклу подій
На Android виклик <DateTimePicker /> відкриває асинхронний системний модальний діалог DatePickerDialog.
Колбек onChange передає об'єкт події DateTimePickerEvent, де поле event.type визначає результат:
event.type === 'set'— користувач підтвердив дату; нове значення міститься вselectedDate.event.type === 'dismissed'— користувач закрив діалог клавішею Back або кнопкою «Скасувати».
Розробник зобов'язаний одразу перевести прапорець показу діалогу в false:
const onDateChange = (event: DateTimePickerEvent, selectedDate?: Date) => {
setShowPicker(false);
if (event.type === 'set' && selectedDate) {
setDate(selectedDate);
}
};
display="inline" або display="spinner"). Для забезпечення передбачуваного UX рекомендується відображати його у Modal із буферизацією вибору: доки користувач крутить барабан, змінюється проміжний стан (Draft State), а коміт у форму відбувається лише при натисканні кнопки «Підтвердити».Дворівнева архітектура роботи з датами
- Шар стану та валідації (Data Layer): оперує чистими екземплярами класу
Dateабо стандартизованими ISO-8601 рядками. Усі порівняння часових інтервалів (Завантаження...) виконуються за таймстемпами. - Шар представлення (Presentation Layer): формує локалізований людиночитабельний підпис («14–18 жовтня 2026») виключно на етапі рендерингу картки, не перетворюючи цей текст назад у модель форми.
Як уникнути перекриття форми клавіатурою
Поява програмної клавіатури (Soft Keyboard) на мобільному пристрої є динамічною зміною геометрії вікна перегляду, яка фізично перекриває до 50% площі екрана.
+---------------------------+ +---------------------------+
| Screen Header | | Screen Header |
+---------------------------+ +---------------------------+
| Field: Name | | Field: Name |
+---------------------------+ +---------------------------+
| Field: Email | ==> | Field: Email (FOCUSED) |
+---------------------------+ +---------------------------+
| Field: Password | | [ SOFT KEYBOARD ] |
+---------------------------+ | [ Q W E R T Y U I O P ] |
| Button: [ SUBMIT ] (LOST) | | [ A S D F G H J K L ] |
+---------------------------+ +---------------------------+
Фізика оклюзії та системні стратегії:
- iOS: системна клавіатура відображається в абсолютному віконному шарі (
UIWindow) поверх застосунку. Без спеціального розрахунку зміщення нижня частина форми стає повністю недосяжною. - Android: операційна система оперує режимами
windowSoftInputMode(adjustResizeабоadjustPan). В умовах сучасної Edge-to-Edge верстки потрібна точна синхронізація з системними інсетами.
Комплексний шаблон вирішення: KAV + ScrollView
Для гарантованого збереження контролю над формою використовується трирівнева ієрархія:
SafeAreaView (Edges: top, left, right)
└── KeyboardAvoidingView (behavior="padding", flex: 1)
└── ScrollView (keyboardShouldPersistTaps="handled", flex: 1)
├── Інпути та контроли форми
└── Кнопка Submit (навіть при зсуві залишається доступною для скролу)
behavior="padding": оптимальна стратегія для iOS, яка додає внутрішнійpaddingBottom, рівний точній висоті клавіатури.behavior="height"/behavior="position": застосовується на Android за умови специфічних налаштувань віконних режимів.
never(дефолт): перший тап по кнопці Submit лише закриває клавіатуру, ігноруючи подіюonPress.handled: якщо тап припав на інтерактивний елемент (Pressable), клавіатура закривається, але подіяonPressнегайно виконується. Обов'язковий параметр для всіх мобільних форм!
on-drag).Архітектура валідації: Client-Side UX проти Server-Side Security
Валідація даних у розподілених мобільних системах функціонує на двох взаємодоповнюючих рівнях:
+-------------------------------------------------------------------------+
| ДВОРІВНЕВА АРХІТЕКТУРА ВАЛІДАЦІЇ |
+-------------------------------------------------------------------------+
| РІВЕНЬ 1: КЛІЄНТСЬКИЙ ШАР (UX) | РІВЕНЬ 2: СЕРВЕРНИЙ ШАР (SECURITY)|
| • Миттєвий зворотний зв'язок | • Абсолютний захист бізнес-правил |
| • Працює без доступу до мережі | • Захист від скомпрометованих API |
| • Підсвічування рамок та підказки | • Криптографічні перевірки та DB |
| • Інструменти: Zod + React Hook Form| • Інструменти: Backend DTO / Zod |
+--------------------------------------+----------------------------------+
Принципи ергономічного відображення помилок:
- Локалізація та контекст: текст помилки рендериться безпосередньо під відповідним інпутом (
errors.field?.message), а не в модальномуAlert.alert(). - Візуальне кодування: рамка помилкового поля забарвлюється у семантичний колір
colors.danger. - Неблокуючий ввід: користувач повинен мати можливість вільно друкувати; помилки первинно відображаються після спроби Submit (
mode: 'onSubmit'), а під час виправлення зникають у реальному часі (reValidateMode: 'onChange').
React Hook Form + Zod: надійний стек валідації
Коли мобільний додаток містить більше ніж 2–3 поля вводу, класичний підхід на базі локального стану useState перетворюється на джерело багів та проблем із продуктивністю:
- Проблема ре-рендерів: кожне натискання клавіші оновлює
useState, змушуючи React перемальовувати весь екран разом із важкими списками, анімаціями та сусідніми компонентами. - Величезний boilerplate: для форми на 7 полів доводиться створювати 7 окремих
useStateдля значень, 7 станів для помилок, окремийisSubmitting, ручні функції скидання та десятки перевірокif (text.length < 2). - Фрагментована валідація: правила перевірки розкидані по обробниках подій або змішані з кодом UI.
Сучасним стандартом в екосистемі React та React Native є тандем двох спеціалізованих інструментів: React Hook Form (керування станом форми та життєвим циклом) та Zod (декларативний опис та валідація схем даних).
┌─────────────────────────────────────────────────────────────────────────────┐
│ ВЗАЄМОДІЯ RHF ТА ZOD │
├──────────────────────────────────────┬──────────────────────────────────────┤
│ 📋 REACT HOOK FORM │ 🛡️ ZOD │
├──────────────────────────────────────┼──────────────────────────────────────┤
│ • Керує станом полів форми │ • Описує правила валідації (схему) │
│ • Не перемальовує зайві компоненти │ • Автоматично генерує типи TypeScript│
│ • Відстежує submit, dirty, touched │ • Парсить та трансформує дані (.trim)│
│ • Надає Controller для нативних полів│ • Повертає зрозумілі тексти помилок │
└──────────────────────────────────────┴──────────────────────────────────────┘
Встановлення необхідних пакетів
Для роботи зв'язки потрібні три пакети:
react-hook-form— ядро керування формою.zod— декларативний валідатор схем.@hookform/resolvers— офіційний адаптер, що з'єднує схеми Zod із валідаційним конвеєром React Hook Form.
npx expo install react-hook-form @hookform/resolvers zod
npm install react-hook-form @hookform/resolvers zod
yarn add react-hook-form @hookform/resolvers zod
bun add react-hook-form @hookform/resolvers zod
# або
pnpm add react-hook-form @hookform/resolvers zod
Що таке Zod і як працює схема даних?
Zod — це бібліотека TypeScript-first валідації, яка дозволяє описати структуру очікуваних даних декларативно у вигляді об'єкта-схеми.
Замість написання ручних умов:
// ❌ Ручний підхід: спагетті з перевірок
if (!title || title.trim().length < 2) {
setError('Назва надто коротка');
}
Ви описуєте контракт даних один раз:
// ✅ Декларативний контракт із Zod
import { z } from 'zod';
export const tripSchema = z.object({
title: z
.string({ required_error: "Назва є обов'язковою" })
.trim()
.min(2, 'Назва має містити щонайменше 2 символи')
.max(50, 'Назва не може перевищувати 50 символів'),
plannedDays: z
.number()
.int()
.min(1, 'Мінімум 1 день')
.max(30, 'Максимум 30 днів'),
isPrivate: z.boolean(),
agreeTerms: z.literal(true, {
errorMap: () => ({ message: 'Необхідно погодитися з правилами' }),
}),
});
// Автоматичне виведення типу TypeScript із Zod-схеми:
export type TripFormValues = z.infer<typeof tripSchema>;
!TIP Завдяки
z.infer<typeof tripSchema>вам не потрібно писатиinterface TripFormValuesвручну! Тип TypeScript завжди на 100% синхронізований зі схемою валідації. Змінили схему — типи оновилися автоматично.
Чому register не працює в React Native і що таке Controller?
Якщо ви раніше працювали з React Hook Form у браузері, ви пам'ятаєте синтаксис:
// 🌐 Тільки у Web: реєстрація через прямий DOM ref
<input {...register('email')} />
У браузері HTML-елемент <input> сам зберігає введений текст у DOM-дереві (Uncontrolled Input). Бібліотека RHF просто чіпляє на нього нативний ref і зчитує input.value лише під час відправки.
Чому це НЕ працює в React Native?
- У React Native немає веб-DOM.
- Компоненти
TextInput,Switch,Slider,Picker— це нативні віджети iOS (UITextField,UISwitch) та Android (EditText,SwitchCompat). - Нативні контроли вимагають явного двостороннього зв'язку через пропси: передачу значення (
value,selectedValue) та підписку на зміну (onChangeText,onValueChange).
Controller як універсальний міст (Adapter Pattern)
Для зв'язку нативних компонентів із React Hook Form використовується компонент <Controller />.
Він реалізує патерн Render Prop: Controller бере на себе всю підписку на внутрішній стан форми і передає у вашу функцію render спеціальний об'єкт field:
<Controller
control={control}
name="title"
render={({ field: { onChange, onBlur, value, ref }, fieldState: { error } }) => (
<View>
<TextInput
ref={ref}
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder="Введіть назву"
/>
{error && <Text style={styles.errorText}>{error.message}</Text>}
</View>
)}
/>
Що містить об'єкт field?
field.value— поточне значення поля з внутрішнього сховища RHF (передаємо уvalue={value}абоselectedValue={value}).field.onChange— функція оновлення значення у RHF (передаємо уonChangeText={onChange}абоonValueChange={onChange}).field.onBlur— колбек втрати фокусу, який повідомляє RHF, що користувач взаємодіяв із полем (touched = true).field.ref— посилання на віджет для автоматичного фокусування на першому помилковому полі при невдалому сабміті.
Що містить об'єкт fieldState?
fieldState.error— об'єкт помилки Zod для цього конкретного поля (error?.message).fieldState.isTouched— чи отримувало поле фокус і чи втрачало його.fieldState.isDirty— чи відрізняється поточне значення відdefaultValues.
Універсальне підключення будь-яких контролів через Controller
Завдяки гнучкості Controller, через нього можна підключити абсолютно будь-який компонент введення:
{/* 1. Текстове поле (TextInput) */}
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput value={value} onChangeText={onChange} onBlur={onBlur} />
)}
/>
{/* 2. Перемикач (Switch) */}
<Controller
control={control}
name="isPrivate"
render={({ field: { onChange, value } }) => (
<Switch value={value} onValueChange={onChange} />
)}
/>
{/* 3. Чекбокс (Checkbox) */}
<Controller
control={control}
name="agreeTerms"
render={({ field: { onChange, value } }) => (
<Checkbox value={value} onValueChange={onChange} />
)}
/>
{/* 4. Повзунок (Slider) */}
<Controller
control={control}
name="plannedDays"
render={({ field: { onChange, value } }) => (
<Slider value={value} onValueChange={onChange} minimumValue={1} maximumValue={14} step={1} />
)}
/>
{/* 5. Селектор (Picker) */}
<Controller
control={control}
name="ticketType"
render={({ field: { onChange, value } }) => (
<Picker selectedValue={value} onValueChange={onChange}>
<Picker.Item label="Стандарт" value="standard" />
<Picker.Item label="VIP" value="vip" />
</Picker>
)}
/>
Покроковий алгоритм побудови форми від А до Я
USER ACTION: Тап по кнопці «Зберегти»
│
▼
handleSubmit(onValid, onInvalid)
│
▼
zodResolver(schema): schema.safeParse(data)
│
┌────────────────┴────────────────┐
▼ ▼
[ VALID ] [ INVALID ]
│ │
▼ ▼
onValid(data) formState.errors
Чисті типізовані дані Оновлення UI з текстом
готові до відправки на API помилок під кожним полем
Крок 1. Опишіть Zod-схему та виведіть тип
import { z } from 'zod';
export const createTripSchema = z.object({
title: z.string().trim().min(2, 'Мінімум 2 символи'),
isPrivate: z.boolean(),
});
export type CreateTripFormValues = z.infer<typeof createTripSchema>;
Крок 2. Ініціалізуйте хук useForm
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const {
control,
handleSubmit,
reset,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<CreateTripFormValues>({
resolver: zodResolver(createTripSchema),
defaultValues: {
title: '',
isPrivate: false,
},
mode: 'onSubmit',
reValidateMode: 'onChange',
});
Крок 3. Напишіть функцію обробки валідних даних
const onValid = async (data: CreateTripFormValues) => {
// data гарантовано відповідає схемі Zod та типу CreateTripFormValues!
console.log('Відправка на сервер:', data);
await api.createTrip(data);
reset(); // Очищення форми до defaultValues після успіху
};
Крок 4. Зв'яжіть кнопку з handleSubmit
<Pressable
onPress={handleSubmit(onValid)}
disabled={isSubmitting}
style={styles.submitBtn}
>
{isSubmitting ? <ActivityIndicator color="#fff" /> : <Text>Зберегти</Text>}
</Pressable>
Повний огляд параметрів конфігурації useForm
schema.safeParse() під час сабміту та транслює згенеровані помилки Zod у структуру formState.errors.undefined до конкретного типу.mode: 'onSubmit', який не відволікає користувача червоними помилками до першої спроби відправки.reValidateMode: 'onChange' гарантує, що помилка зникне миттєво, щойно користувач введе коректні дані.<Controller control={control} /> для реєстрації полів у системі підписок.errors— словник активних помилок{ [fieldName]: { message: string } }.isSubmitting— булевий прапорець виконання асинхронного колбекаonValid.isDirty—true, якщо хоча б одне поле було змінене відносноdefaultValues.isValid— чи проходить форма валідацію в поточний момент.
defaultValues та очищає історію помилок errors.Демо UX помилок
Loading…
react-native-web · not a real device
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
Switch,
View,
useColorScheme,
} from 'react-native';
type Errors = { name?: string; email?: string; terms?: 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 border = dark ? '#3a3a3c' : '#e2e8f0';
const danger = dark ? '#f87171' : '#dc2626';
const primary = dark ? '#3b82f6' : '#2563eb';
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [terms, setTerms] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [done, setDone] = useState(false);
const onSubmit = () => {
const e: Errors = {};
if (name.trim().length < 2) e.name = 'Ім’я — щонайменше 2 символи';
if (!email.includes('@') || email.trim().length < 5) e.email = 'Схоже, це не email';
if (!terms) e.terms = 'Потрібна згода';
setErrors(e);
setDone(Object.keys(e).length === 0);
};
if (done) {
return (
<View style={[styles.center, { backgroundColor: bg }]}>
<Text style={{ color: text, fontWeight: '800', fontSize: 22 }}>Готово</Text>
<Text style={{ color: muted, marginTop: 8 }}>{name} · {email}</Text>
<Pressable
onPress={() => {
setDone(false);
setName('');
setEmail('');
setTerms(false);
}}
style={[styles.btn, { backgroundColor: primary, marginTop: 16 }]}
>
<Text style={styles.btnText}>Ще раз</Text>
</Pressable>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: bg }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={{ padding: 16, gap: 10 }} keyboardShouldPersistTaps="handled">
<Text style={{ color: text, fontWeight: '800', fontSize: 22 }}>Реєстрація</Text>
<Text style={{ color: muted, fontSize: 13 }}>
Текст + Switch-згода. Натисніть «Далі» з порожніми полями.
</Text>
<Text style={[styles.label, { color: muted }]}>Ім’я</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Олена"
placeholderTextColor={muted}
style={[
styles.input,
{ backgroundColor: surface, borderColor: errors.name ? danger : border, color: text },
]}
/>
{errors.name ? <Text style={{ color: danger }}>{errors.name}</Text> : null}
<Text style={[styles.label, { color: muted }]}>Email</Text>
<TextInput
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
placeholder="you@example.com"
placeholderTextColor={muted}
style={[
styles.input,
{ backgroundColor: surface, borderColor: errors.email ? danger : border, color: text },
]}
/>
{errors.email ? <Text style={{ color: danger }}>{errors.email}</Text> : null}
<View style={styles.row}>
<Text style={{ color: text, flex: 1 }}>Погоджуюсь на обробку</Text>
<Switch value={terms} onValueChange={setTerms} trackColor={{ true: primary, false: border }} />
</View>
{errors.terms ? <Text style={{ color: danger }}>{errors.terms}</Text> : null}
<Pressable onPress={onSubmit} style={[styles.btn, { backgroundColor: primary }]}>
<Text style={styles.btnText}>Далі</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 16 },
label: { fontSize: 12, fontWeight: '700', textTransform: 'uppercase', marginTop: 6 },
input: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
minHeight: 48,
},
row: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 8 },
btn: { marginTop: 12, paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
btnText: { color: '#fff', fontWeight: '800' },
});
Класифікація антипатернів розробки мобільних форм
| Антипатерн | Системна причина | Наслідки для користувача (UX Failure) | Інженерне вирішення |
|---|---|---|---|
Універсальний TextInput | Лінь інтегрувати Community-пакети. | Одруки в датах («31.02»), невалідні регіони, конфлікти регістрів. | Використовувати Picker, Switch, DateTimePicker. |
Модальні Alert для помилок | Ігнорування вбудованого рендерингу errors. | Блокування інтерфейсу, відсутність візуальної прив'язки до помилкового поля. | Рендерити inline-текст помилки безпосередньо під рамкою поля. |
Імперативна валідація в onPress | Відсутність декларативної схеми Zod. | Каскад спагетті-коду if/else, неможливість повторного використання на бекенді. | Описувати єдину схему z.object({ ... }) через zodResolver. |
Форма без ScrollView | Тестування лише на великих планшетах/емуляторах. | Обрізання форми на компактних екранах та при відкритті клавіатури. | Обов'язкова обгортка форми у ScrollView із flex: 1. |
Втрата тапу (keyboardShouldPersistTaps) | Залишено дефолтне значення never. | Перший тап по кнопці Submit поглинається закриттям клавіатури; відчуття «зламаної кнопки». | Завжди передавати keyboardShouldPersistTaps="handled". |
| Зайва автокапіталізація | Забутий autoCapitalize="none". | Спотворення логінів та email (User@domain.com), відмова в авторизації. | Явно вимикати autoCapitalize="none" та autoCorrect={false} для технічних полів. |
| Клієнт як єдиний захист | Хибне припущення про абсолютну безпеку клієнта. | Вразливість до підміни API-пакетів та ін'єкцій невалідних даних. | Дублювати Zod-схеми на бекенд-сервері для повної валідації вхідних DTO. |
Міні-проєкт: «Реєстрація на подію»
Мета
Окремий застосунок поза Nomad. Нижче — шлях від А до Я: команди, структура, повний код файлів. Можна копіювати й запускати без «домалюйте самі».
Сюжет: реєстрація на умовну подію. Два стани без Router:
- Форма (усі типи контролів + RHF + Zod).
- Успіх (підсумок + «Ще раз»).
Що має вийти
| Поле | Контрол |
|---|---|
| Ім’я | TextInput |
TextInput + keyboardType="email-address" | |
| Місць 1–5 | Slider |
| Тип квитка | Picker |
| Нагадування | Switch |
| Згода з правилами | Checkbox + z.literal(true) |
| Коментар | TextInput multiline |
| Submit | Pressable + handleSubmit |
Обов’язково: SafeAreaProvider → KeyboardAvoidingView → ScrollView + keyboardShouldPersistTaps="handled".
Структура
event-signup/
App.tsx ← увесь застосунок (повний код нижче)
package.json
app.json
index.ts
…
Крок 1. Створити проєкт і поставити пакети
1. Скарфолд
npx create-expo-app@latest event-signup -t blank-typescript
cd event-signup
2. Залежності
npx expo install react-native-safe-area-context @react-native-community/slider @react-native-community/checkbox @react-native-picker/picker
npm install react-hook-form @hookform/resolvers zod
Якщо npm свариться на peers:
npm install react-hook-form @hookform/resolvers zod --legacy-peer-deps
3. Запуск після підстановки App.tsx
npx expo start
Відкрийте в Expo Go.
Крок 2. Повний код App.tsx
Повністю замініть файл App.tsx на код нижче (розгорніть collapsible і скопіюйте цілком).
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Picker } from '@react-native-picker/picker';
import Checkbox from '@react-native-community/checkbox';
import Slider from '@react-native-community/slider';
const TICKETS = [
{ value: 'standard' as const, label: 'Звичайний' },
{ value: 'student' as const, label: 'Студентський' },
{ value: 'child' as const, label: 'Дитячий' },
];
const signupSchema = z.object({
name: z.string().trim().min(2, 'Вкажіть ім’я (мін. 2 символи)'),
email: z.string().trim().email('Некоректний email'),
seats: z.number().min(1, 'Мін. 1 місце').max(5, 'Макс. 5 місць'),
ticketType: z.enum(['standard', 'student', 'child']),
remindMe: z.boolean(),
acceptedRules: z.literal(true, {
error: 'Потрібно прийняти правила події',
}),
note: z
.string()
.trim()
.max(200, 'Коментар — макс. 200 символів')
.optional()
.or(z.literal('')),
});
type SignupValues = z.infer<typeof signupSchema>;
const ticketLabel: Record<SignupValues['ticketType'], string> = {
standard: 'Звичайний',
student: 'Студентський',
child: 'Дитячий',
};
function FieldLabel({ children }: { children: string }) {
return <Text style={styles.label}>{children}</Text>;
}
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <Text style={styles.error}>{message}</Text>;
}
function SignupForm({ onSuccess }: { onSuccess: (data: SignupValues) => void }) {
const {
control,
handleSubmit,
watch,
formState: { errors },
} = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
defaultValues: {
name: '',
email: '',
seats: 1,
ticketType: 'standard',
remindMe: true,
acceptedRules: false as unknown as true,
note: '',
},
mode: 'onSubmit',
reValidateMode: 'onChange',
});
const seats = watch('seats');
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
style={styles.flex}
contentContainerStyle={styles.form}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
<Text style={styles.title}>Реєстрація на подію</Text>
<Text style={styles.muted}>
Заповніть поля. Помилки з’являться після «Зареєструватись», якщо щось
не так.
</Text>
<FieldLabel>Ім’я</FieldLabel>
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.name && styles.inputErr]}
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder="Олена Коваленко"
autoCapitalize="words"
returnKeyType="next"
/>
)}
/>
<FieldError message={errors.name?.message} />
<FieldLabel>Email</FieldLabel>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.email && styles.inputErr]}
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
)}
/>
<FieldError message={errors.email?.message} />
<FieldLabel>Тип квитка</FieldLabel>
<Controller
control={control}
name="ticketType"
render={({ field: { onChange, value } }) => (
<View style={[styles.pickerWrap, errors.ticketType && styles.inputErr]}>
<Picker selectedValue={value} onValueChange={onChange}>
{TICKETS.map((t) => (
<Picker.Item key={t.value} label={t.label} value={t.value} />
))}
</Picker>
</View>
)}
/>
<FieldError message={errors.ticketType?.message} />
<FieldLabel>Кількість місць: {seats}</FieldLabel>
<Controller
control={control}
name="seats"
render={({ field: { onChange, value } }) => (
<Slider
minimumValue={1}
maximumValue={5}
step={1}
value={value}
onValueChange={onChange}
minimumTrackTintColor="#2563EB"
maximumTrackTintColor="#E2E8F0"
thumbTintColor="#2563EB"
/>
)}
/>
<FieldError message={errors.seats?.message} />
<Controller
control={control}
name="remindMe"
render={({ field: { onChange, value } }) => (
<View style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowTitle}>Нагадування</Text>
<Text style={styles.muted}>
Switch: надіслати нагадування перед подією
</Text>
</View>
<Switch
value={value}
onValueChange={onChange}
trackColor={{ false: '#E2E8F0', true: '#2563EB' }}
thumbColor="#FFFFFF"
/>
</View>
)}
/>
<Controller
control={control}
name="acceptedRules"
render={({ field: { onChange, value } }) => (
<View style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowTitle}>Згода з правилами</Text>
<Text style={styles.muted}>
Обов’язкова галочка (без неї submit не пройде)
</Text>
</View>
<Checkbox
value={Boolean(value)}
onValueChange={onChange}
tintColors={{ true: '#2563EB', false: '#94A3B8' }}
onCheckColor="#FFFFFF"
onFillColor="#2563EB"
onTintColor="#2563EB"
boxType="square"
/>
</View>
)}
/>
<FieldError message={errors.acceptedRules?.message} />
<FieldLabel>Коментар (необов’язково)</FieldLabel>
<Controller
control={control}
name="note"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, styles.multi, errors.note && styles.inputErr]}
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder="Алергії, побажання до місця…"
multiline
textAlignVertical="top"
maxLength={200}
/>
)}
/>
<FieldError message={errors.note?.message} />
<Pressable style={styles.btn} onPress={handleSubmit(onSuccess)}>
<Text style={styles.btnText}>Зареєструватись</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
function SuccessScreen({
data,
onAgain,
}: {
data: SignupValues;
onAgain: () => void;
}) {
return (
<View style={styles.center}>
<Text style={styles.title}>Ви зареєстровані</Text>
<Text style={styles.muted}>{data.name}</Text>
<Text style={styles.muted}>{data.email}</Text>
<Text style={styles.summary}>
Квиток: {ticketLabel[data.ticketType]} · місць: {data.seats}
</Text>
<Text style={styles.summary}>
Нагадування: {data.remindMe ? 'так' : 'ні'}
</Text>
{data.note ? (
<Text style={[styles.muted, { marginTop: 8 }]}>{data.note}</Text>
) : null}
<Pressable style={[styles.btn, { marginTop: 24 }]} onPress={onAgain}>
<Text style={styles.btnText}>Ще раз</Text>
</Pressable>
</View>
);
}
export default function App() {
const [done, setDone] = useState<SignupValues | null>(null);
return (
<SafeAreaProvider>
<SafeAreaView style={styles.safe}>
{done ? (
<SuccessScreen data={done} onAgain={() => setDone(null)} />
) : (
<SignupForm onSuccess={(data) => setDone(data)} />
)}
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
flex: { flex: 1 },
form: { padding: 16, gap: 6, paddingBottom: 40 },
center: {
flex: 1,
padding: 24,
justifyContent: 'center',
alignItems: 'center',
},
title: {
fontSize: 24,
fontWeight: '800',
color: '#0F172A',
marginBottom: 4,
},
muted: { color: '#64748B', fontSize: 13, marginBottom: 8 },
label: {
marginTop: 10,
fontSize: 12,
fontWeight: '700',
color: '#64748B',
textTransform: 'uppercase',
},
input: {
borderWidth: 1,
borderColor: '#E2E8F0',
backgroundColor: '#FFFFFF',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
minHeight: 48,
color: '#0F172A',
},
multi: { minHeight: 96 },
inputErr: { borderColor: '#DC2626' },
error: { color: '#DC2626', fontSize: 13, marginTop: 4 },
pickerWrap: {
borderWidth: 1,
borderColor: '#E2E8F0',
borderRadius: 12,
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: 12,
},
rowText: { flex: 1, gap: 2 },
rowTitle: { fontWeight: '700', color: '#0F172A' },
btn: {
marginTop: 20,
backgroundColor: '#2563EB',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
btnText: { color: '#FFFFFF', fontWeight: '800', fontSize: 16 },
summary: { color: '#0F172A', marginTop: 6, fontWeight: '600' },
});
Крок 3. Що всередині (короткий розбір)
Схема
signupSchema — одне місце правил. acceptedRules: z.literal(true) вимагає галочку. ticketType збігається з Picker.
useForm
resolver: zodResolver(signupSchema), defaultValues (згода спочатку false), mode: 'onSubmit', reValidateMode: 'onChange'.
Controller
Кожне поле підключене явно: TextInput → onChangeText; Switch/Checkbox/Slider → onValueChange; Picker → selectedValue / onValueChange.
Два екрани
done === null — форма; інакше — успіх. «Ще раз» скидає done у null.
Крок 4. Ручна перевірка
npx expo start→ Expo Go.- Одразу «Зареєструватись» → помилки під полями.
- Коректні дані + галочка → екран успіху.
- «Ще раз» → порожня форма.
- Фокус у коментарі на малому екрані — кнопка доступна скролом.
Критерій «готово»
- Проєкт створено командами кроку 1
-
App.tsx— повний код з collapsible (усі контроли) - Порожній submit показує помилки під полями
- Без згоди немає екрана успіху
- Успіх + «Ще раз»
- KAV + ScrollView +
keyboardShouldPersistTaps
error у literal). На Zod 3 підставте API повідомлень з docs вашої major-версії — ідея схеми та сама.Nomad: форма створення поїздки
Навіщо користувачу
До цієї статті кнопка «Нова поїздка» або нічого не робила, або лише логувала в консоль. Тепер вона відкриває екран форми. Людина заповнює назву, регіон зі списку, дати, опис, тривалість повзунком, за бажанням позначає «приватна» / «є план», підтверджує локальне збереження — і після успіху повертається на стрічку, де нова картка лежить зверху.
На картці видно не лише title: мітки «приватна», «є план», орієнтовну кількість днів — щоб було видно, що boolean/slider реально потрапили в модель Trip.
Нитка проєкту
Уже є з попередніх статей (і це не зникає):
ThemeProvider, чіпи light/dark/system;Screen,AppText, sharedButton;- домашня стрічка на FlashList + горизонтальні місця;
- pull-to-refresh, sticky CTA «Нова поїздка»;
- mock-поїздки й mock-місця.
Що з’являється саме в цій статті:
| Шматок | Роль |
|---|---|
| Пакети RHF, Zod, picker, slider, checkbox, datetimepicker | залежності форми |
createTripSchema | правила Zod |
TripsProvider / useTrips | спільний список між home і формою |
TextField, FormRow, DateField | UI-обгортки контролів |
CreateTripForm | уся форма + Controller |
app/create-trip.tsx | екран-маршрут |
розширення типу Trip | isPrivate, plannedDays, hasItinerary |
Стан поїздок піднято в Context (TripsProvider), бо форма на іншому екрані, ніж список. Без спільного стейту router.back() не знав би, куди ділась щойно створена поїздка. Пізніше в курсі Context замінять / доповнять RTK — ідея «одне джерело списку» лишиться.
router.push('/create-trip') і router.back() — мінімум Expo Router, щоб форма жила на окремому екрані. Як влаштовані layouts, tabs, typed routes — наступні статті модуля навігації. Зараз достатньо: файл app/create-trip.tsx ↔ адреса /create-trip.Встановлення
cd /path/to/nomad
npx expo install @react-native-community/datetimepicker @react-native-picker/picker @react-native-community/slider @react-native-community/checkbox
npm install react-hook-form @hookform/resolvers zod
Повний знімок проєкту
Перевірка
- Чіпи теми на home працюють.
- Списки поїздок і місць — як раніше.
- «Нова поїздка» → форма з TextInput, Picker, датами, Slider, Switch, Checkbox.
- Порожній submit / без згоди → помилки під полями.
- Успіх → картка зверху; мітки приватності / плану.
- Pull-to-refresh скидає до mock (локальні зникають — до offline-модуля).
Коміт
cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: create trip form with validation
Material: content/15.react-native/10.forms-input-validation.md
EOF
)"
git push
У публічному репо коміт з’явиться після push; локально — git pull.
Результат (прев’ю UX)
Host прев’ю без community-пакетів і RHF — імітація екранів. Повний стек — у репо й Expo Go.
Loading…
react-native-web · not a real device
import { useState } from 'react';
import {
FlatList,
Image,
Pressable,
StyleSheet,
Switch,
Text,
TextInput,
View,
useColorScheme,
} from 'react-native';
type Trip = {
id: string;
title: string;
dateLabel: string;
region: string;
coverUri: string;
isPrivate?: boolean;
};
const light = {
bg: '#F8FAFC', surface: '#fff', text: '#0f172a', muted: '#64748b',
primary: '#2563eb', border: '#e2e8f0', onPrimary: '#fff', danger: '#dc2626',
};
const dark = {
bg: '#000', surface: '#1c1c1e', text: '#f5f5f7', muted: '#a1a1aa',
primary: '#3b82f6', border: '#3a3a3c', onPrimary: '#fff', danger: '#f87171',
};
const SEED: Trip[] = [
{
id: '1',
title: 'Карпати на вихідні',
dateLabel: '12–14 бер. 2026',
region: 'Карпати',
coverUri: 'https://picsum.photos/seed/nomad1/800/400',
},
];
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 [screen, setScreen] = useState<'home' | 'form'>('home');
const [trips, setTrips] = useState(SEED);
const [title, setTitle] = useState('');
const [isPrivate, setIsPrivate] = useState(false);
const [accepted, setAccepted] = useState(false);
const [errors, setErrors] = useState<{ title?: string; accepted?: string }>({});
const submit = () => {
const e: typeof errors = {};
if (title.trim().length < 2) e.title = 'Назва — мін. 2 символи';
if (!accepted) e.accepted = 'Потрібна згода';
setErrors(e);
if (Object.keys(e).length) return;
setTrips((prev) => [
{
id: `local-${Date.now()}`,
title: title.trim(),
region: 'Карпати',
dateLabel: 'сьогодні',
coverUri: `https://picsum.photos/seed/${encodeURIComponent(title)}/800/400`,
isPrivate,
},
...prev,
]);
setTitle('');
setIsPrivate(false);
setAccepted(false);
setScreen('home');
};
if (screen === 'form') {
return (
<View style={[styles.screen, { backgroundColor: c.bg }]}>
<View style={[styles.top, { borderColor: c.border }]}>
<Pressable onPress={() => setScreen('home')}>
<Text style={{ color: c.primary, fontWeight: '700' }}>← Назад</Text>
</Pressable>
<Text style={[styles.brand, { color: c.text, marginTop: 8 }]}>Нова поїздка</Text>
</View>
<View style={{ padding: 16, gap: 12 }}>
<Text style={{ color: c.muted, fontSize: 12, fontWeight: '700' }}>НАЗВА (TextInput)</Text>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Карпати на вихідні"
placeholderTextColor={c.muted}
style={[
styles.input,
{
backgroundColor: c.surface,
borderColor: errors.title ? c.danger : c.border,
color: c.text,
},
]}
/>
{errors.title ? <Text style={{ color: c.danger }}>{errors.title}</Text> : null}
<View style={styles.row}>
<Text style={{ color: c.text, flex: 1 }}>Приватна (Switch)</Text>
<Switch
value={isPrivate}
onValueChange={setIsPrivate}
trackColor={{ true: c.primary, false: c.border }}
/>
</View>
<View style={styles.row}>
<Text style={{ color: c.text, flex: 1 }}>Згода (імітація Checkbox)</Text>
<Switch
value={accepted}
onValueChange={setAccepted}
trackColor={{ true: c.primary, false: c.border }}
/>
</View>
{errors.accepted ? <Text style={{ color: c.danger }}>{errors.accepted}</Text> : null}
<Pressable onPress={submit} style={[styles.cta, { backgroundColor: c.primary }]}>
<Text style={{ color: c.onPrimary, fontWeight: '800' }}>Створити (Pressable)</Text>
</Pressable>
</View>
</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 }}>
{trips.length} поїздок · форма з різними контролями
</Text>
</View>
<FlatList
style={{ flex: 1 }}
data={trips}
keyExtractor={(t) => t.id}
contentContainerStyle={{ padding: 16 }}
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}
{item.isPrivate ? ' · приватна' : ''}
</Text>
</View>
</View>
)}
/>
<View style={[styles.footer, { borderColor: c.border, backgroundColor: c.bg }]}>
<Pressable onPress={() => setScreen('form')} 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' },
card: { borderWidth: 1, borderRadius: 16, overflow: 'hidden', marginBottom: 12 },
cover: { width: '100%', height: 120, backgroundColor: '#cbd5e1' },
footer: { borderTopWidth: 1, padding: 16 },
cta: { paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
input: { borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, fontSize: 16 },
row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
});
Практичні завдання
Базовий рівень
- Своїми словами пояснити, коли
Switch, а коли Checkbox. - Зібрати
TextInput+Switch+Pressableбез RHF. - Пояснити, навіщо
value+ колбек зміни на будь-якому контролі.
Середній рівень
- Міні-проєкт «Реєстрація на подію» з RHF + Zod і щонайменше чотирма типами контролів.
- У Nomad додати Picker.Item «Закордон» у
TRIP_REGIONSі прогнати схему. - Ланцюжок focus між двома TextInput через
returnKeyType="next".
Професійний рівень
- Порівняти
mode: 'onBlur'іonSubmitна формі з 6+ полями — короткий висновок. - Unit-тести на
createTripSchema.safeParse(без UI). - Нотатка: Checkbox і New Architecture — що каже warning Expo і який план Б (
Pressable+ іконка).
Часті запитання
Controller (або useController) і ручна прив’язка props до контролу.keyboardShouldPersistTaps="handled" на ScrollView.Picker.Item у dark theme.TripsProvider). Персист — у модулі MMKV/SQLite.useState. RHF виправдовує себе на середніх/великих формах і спільному стилі з web.Що далі
Тепер у вас є словник контролів і спосіб зв’язати їх правилами. Далі — основи Expo Router: тека app/, layout, Tabs і Stack, Link і useRouter. Екран create-trip уже натякає на стопку екранів; наступна стаття робить навігацію свідомою моделлю всього застосунку (вкладки «Поїздки / Місця / Ще»).
TextInput · Pressable · Button · Switch · Slider · Picker · Checkbox · DateTimePicker (Expo) · React Hook Form · Zod · Nomad
Списки та віртуалізація
FlatList, SectionList, FlashList, pull-to-refresh, порожній і помилковий стан, пагінація UI — каталог книг і стрічки поїздок/місць у Nomad
Основи Expo Router
Архітектура File-based routing у Expo — сегрегація app/ та src/, життєвий цикл макетів _layout, навігатори Stack і Tabs, декларативна та імперативна маршрутизація через Link та useRouter, механіка повернення назад, Typed Routes, міні-проєкт «Довідник міст» та навігаційна оболонка Nomad