React Native

Форми, ввід і валідація

Усі основні контроли форми в React Native — TextInput, Pressable, Button, Switch, Checkbox, Slider, Picker, date picker; клавіатура; React Hook Form і Zod; реєстрація на подію та створення поїздки в Nomad

Форми, ввід і валідація

З чого почати

У попередній статті ми навчилися показувати довгі списки: поїздки гортаються, місця лежать у горизонтальній стрічці, pull-to-refresh імітує оновлення. Але щоденник подорожей, у якому не можна додати нову поїздку, — це музей чужих карток. Користувач натискає «Нова поїздка» і очікує форму: назву, регіон, дати, опис, можливо кілька перемикачів і згоду «я розумію, куди дінуться дані».

Якщо поля лишити порожніми і натиснути «Створити», застосунок не повинен мовчки нічого не зробити і не повинен «падати». Він має пояснити, що не так — людською мовою, поруч із полем, а не одним загальним Alert на весь екран.

У вебі ви вже, ймовірно, збирали форми на React: <input>, onChange, інколи React Hook Form і Zod. На телефоні ідеї ті самі (стан, правила, submit), але середовище інше.

  1. Замість HTML-елементів — нативні контроли операційної системи (або бібліотеки, які їх обгортають).
  2. Замість вільної висоти вікна браузера — програмна клавіатура (soft keyboard), яка може закрити половину екрана.
  3. Замість одного «універсального» <input type="…">різні компоненти під різні типи відповідей: текст, так/ні, список, число з діапазону, дата.

Мета цієї статті — не «швидко показати TextInput», а зібрати словник контролів форми і навчитися зв’язувати їх із валідацією. Складні теми навігації (глибокий Expo Router) і мережі (справжній API) з’являться пізніше; тут ми свідомо тримаємося форм і стану на клієнті.

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

  1. Пояснити, які контроли існують і коли який обирати.
  2. Зібрати контрольований TextInput і зрозуміти value + onChangeText.
  3. Зробити дію форми через Pressable і зрозуміти, чим він відрізняється від системного Button.
  4. Підключити Switch, Checkbox, Slider, Picker, date picker.
  5. Не «вбити» UX клавіатурою (KeyboardAvoidingView, ScrollView, keyboardShouldPersistTaps).
  6. Зв’язати все через React Hook Form (Controller) і Zod (zodResolver).
  7. Зібрати міні-проєкт «Реєстрація на подію» і форму створення поїздки в Nomad.
Головна думка. Форма на телефоні — це не один TextInput. Це набір типів відповіді (текст, прапорець, список, діапазон, дата) плюс стан і правила (чи можна відправити) плюс layout навколо клавіатури. Клієнтська валідація допомагає людині; вона не замінює перевірку на сервері, коли з’явиться API.
Живі прев’ю на цій сторінці. У курсі є блок «телефон у браузері» (::react-native-preview). Він крутить react-native-web: знайомі компоненти з react-native (TextInput, Switch, Pressable) там видно одразу.Чого не буде в цьому iframe так само, як на телефоні:
  • «справжньої» програмної клавіатури iPhone/Android;
  • системного календаря дати;
  • пакетів на кшталт Picker / Checkbox / Slider;
  • бібліотек React Hook Form і Zod (їх немає в бандлі прев’ю).
Тому: ідею UX дивіться в демо на сторінці, а повну поведінку — у Expo Go або симуляторі на коді міні-проєкту й Nomad.

Код наскрізного застосунку: github.com/arakviel/nomad.


Що таке форма на телефоні (простими словами)

Форма (form) — це екран (або частина екрана), де застосунок запитує дані в людини, щоб потім щось із ними зробити: створити запис, увійти в акаунт, змінити налаштування, відфільтрувати список.

На вебі форма часто виглядає як прямокутники для тексту + кнопка «Надіслати». На телефоні картинка ширша. Людині зручніше не друкувати, якщо можна обрати:

Питання до користувачаЗручний контроль
«Як назвати поїздку?»вільний текст → TextInput
«Який регіон із наших?»фіксований список → Picker
«Коли початок?»дата → date picker
«Поїздка приватна?»так/ні одним рухом → Switch
«Є план маршруту?»галочка → Checkbox
«Скільки днів орієнтовно?»число з діапазону → Slider
«Зберегти / скасувати»дія → Pressable / Button

Якщо на все ставити лише TextInput, форма стає довгою, повільною для заповнення однією рукою і повною помилок друку («Карпатт» замість «Карпати»).

TextInput

Вільний текст і багаторядковий опис. Відкриває клавіатуру.

Pressable / Button

Дія: submit, скасувати, «далі». Не поле даних, а команда.

Switch

Один бінарний стан у вигляді перемикача «увімкнено / вимкнено».

Checkbox

Галочка: згода, опція «так маю». Часто в списках опцій.

Slider

Число з діапазону без клавіатури (гучність, дні, рейтинг).

Picker

Один варіант із фіксованого списку (регіон, валюта, статус).

Date picker

Системний UI дати/часу. Не «рядок 12.03.26» руками.

RHF + Zod

Стан форми, правила, повідомлення про помилки, submit.

Місток з web React (не таблицею «для своїх»)

У браузері ви думаєте категоріями HTML. На телефоні HTML немає. Нижче — не «словник жаргону», а пояснення, що саме змінюється в голові.

Поле тексту

У вебі: <input> або <textarea>, подія onChange, значення часто з event.target.value.

У React Native: компонент TextInput. Подія зручніше читається як onChangeText: у колбек одразу приходить рядок, без розгортання event. Якщо поле контрольоване, ви передаєте value={…} і в onChangeText оновлюєте state (або RHF).

Тип поля

У вебі один prop type="email" одночасно натякає браузеру: і яку клавіатуру показати на телефоні, і яку базову перевірку зробити, і як малювати поле. У React Native немає одного еквівалента type.

Замість цього ви збираєте поведінку з кількох незалежних props:

  • keyboardType — яку програмну клавіатуру відкрити (email-address, numeric, phone-pad, …). Це впливає на розкладку, не на те, чи рядок «справді email».
  • secureTextEntry — чи маскувати символи (пароль). Окремий прапорець, бо «пароль» — це не тип клавіатури, а режим відображення.
  • multiline — одне поле чи кілька рядків (роль textarea).
  • плюс ваші стилі (висота, бордер) і валідація (Zod).

Чому так важливо не плутати клавіатуру з валідацією: людина може відкрити keyboardType="email-address" і все одно вставити з буфера рядок без @. Система не зобов’язана заборонити вставку. Тому зручна клавіатура — для швидкості вводу, а Zod — для відповіді «це ще не схоже на email» під полем.

Кнопка

У вебі кнопка часто живе всередині <form>: type="submit" сам знає, що «натисни Enter / цю кнопку = відправити форму». Браузер збирає поля й ініціює submit.

У React Native немає HTML-форми і немає вбудованого «enter = submit усієї форми» для змішаного екрана. Тому submit — це явний обробник:

  1. Людина тапає ваш Pressable (або системний Button — про нього нижче).
  2. У onPress ви викликаєте handleSubmit(onValid) з React Hook Form (або свою перевірку).
  3. Якщо дані ок — виконуєте збереження / навігацію; якщо ні — показуєте errors під полями.

Клавіша Return на soft keyboard може допомогти через onSubmitEditing на конкретному TextInput, але:

  • на iOS і Android підписи й поведінка Return відрізняються;
  • Switch, Slider, Picker, Checkbox не відкривають клавіатуру — Return там просто немає;
  • багато людей ніколи не шукають Return і одразу тиснуть велику кнопку внизу.

Отже, велика кнопка «Створити» / «Надіслати» — обов’язковий елемент форми, а не опційний декор.

Списки вибору, галочки, повзунки

У вебі: <select>, <input type="checkbox">, <input type="range">, <input type="date">.

У React Native:

WebReact Native (типовий шлях у цьому курсі)
<select>@react-native-picker/picker
checkbox@react-native-community/checkbox (або свій UI на Pressable)
range@react-native-community/slider
date@react-native-community/datetimepicker (Expo)
toggle «як iOS»вбудований Switch з react-native

Частина рядків у правій колонці — не «з коробки React Native», а окремі пакети спільноти (community packages). Історично деякі контроли жили в core, потім їх винесли в окремі репозиторії (легше оновлювати, різні команди підтримки). Інші ніколи не були універсальними для iOS+Android в одному core-API.

Практичний висновок для вас:

  1. Ставите їх командою npx expo install ім’я-пакета (не «голим» npm install навмання) — Expo підбере версію, сумісну з вашим SDK.
  2. Після install інколи з’являється рядок у plugins у app.json (як для date picker) — це нормально.
  3. У прев’ю на сайті курсу цих пакетів немає — перевірка лише в Expo Go / симуляторі.

React Hook Form (коротко на містку; деталі — нижче)

React Hook Form — бібліотека, яка тримає стан усієї форми (значення полів, помилки, факт submit) і зменшує кількість ручного useState на кожне поле.

У веб-документації RHF часто показують так:

<input {...register('email')} />

register «підписує» DOM-input до форми через ref і події браузера. У React Native немає того самого DOM і тих самих ref-інпутів. Тому офіційний шлях для RN — компонент Controller: ви кажете «поле називається email», а в render самі з’єднуєте value / onChange / onBlur зі своїм TextInput, Switch тощо.

Повний розбір RHF + Zod — у розділі нижче в цій статті. На містку з вебом важливо лише: звичка RHF лишається, спосіб підключення контролу — через Controller, не через register + spread на <input>.

Web-звичка, яка болить. На кожне поле — свій useState, у кнопці — купа if (!email) …, помилки — один Alert.alert('Помилка'). Для двох полів це терпимо. Для екрана з десятьма контролями правила роз’їжджаються, повідомлення не біля поля, тестувати важко. Тому в курсі для «справжніх» форм беремо схему (Zod) + RHF.

Життєва сцена: клавіатура з’їла Submit

Уявіть реєстрацію однією рукою в метро. Фокус у полі «Email» — знизу виїхала програмна клавіатура і закрила кнопку «Надіслати». Ви свайпаєте навмання, втрачаєте фокус, клавіатура ховається, кнопка знову з’являється. Людина не каже «немає KeyboardAvoidingView» — каже «форма крива».

Важливо: Switch, Checkbox, Slider, Picker клавіатуру самі не відкривають. Проблема «кнопка під клавіатурою» стосується переважно екранів, де є TextInput. Для боротьби з нею в React Native є контейнер KeyboardAvoidingView (часто скорочують KAV): він зсуває або стискає вміст, коли клавіатура відкрита. Деталі — у розділі про клавіатуру нижче.

Але форма часто змішана: зверху текст, знизу галочки, ще нижче кнопка. Тому типовий каркас — завжди ScrollView (форма довша за екран або стане такою з клавіатурою) + за потреби KeyboardAvoidingView, навіть якщо «не всі» поля текстові.

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

rectangle "Екран без KAV" as A {
  rectangle "Поле email (focus)" as E
  rectangle "Кнопка Submit\n(під клавіатурою)" as B
  rectangle "Soft keyboard" as K
}

rectangle "Екран з KAV + ScrollView" as C {
  rectangle "Scrollable form" as S
  rectangle "Поле + кнопка\nвидимі після зсуву" as V
}

note right of A
  Користувач не бачить Submit
end note

note right of C
  Контент підіймається / скролиться
end note

@enduml

Контрольовані значення: одна ідея для всіх контролів

Перш ніж розбирати кожен компонент, зафіксуємо спільний патерн.

У React (і на вебі, і в RN) поле називають контрольованим (controlled), коли джерело правди — ваш state (або RHF), а UI лише відображає це значення і повідомляє про зміни.

КонтролProp значенняProp зміни
TextInputvalue (string)onChangeText
Switchvalue (boolean)onValueChange
Checkboxvalue (boolean)onValueChange
Slidervalue (number)onValueChange
PickerselectedValueonValueChange
DateTimePickervalue (Date)onChange (event, date)

Якщо передати prop значення (value / selectedValue), але не оновлювати state у колбеці зміни, контроль «залипне». Людина друкує — символи не з’являються; тягне Switch — він стрибає назад; крутить Slider — підпис не змінюється. З боку виглядає як «зламаний компонент», хоча насправді зламаний зв’язок «UI → state → знову UI».

Це одна з найчастіших помилок на старті. Правило одне на всі контроли таблиці вище: є value — є onChange, який це value оновлює.


TextInput — поле для тексту

TextInput — вбудований компонент React Native для введення тексту.

Важлива відмінність від вебу: на екрані з’являється не HTML-елемент <input>, а справжнє поле операційної системи. На iPhone це механізм на кшталт UITextField, на Android — EditText (назви з нативних SDK; вам їх не треба імпортувати вручну). React Native лише каже системі: «намалюй текстове поле ось тут і повідом, коли текст зміниться».

Тому поле «відчувається» як у інших застосунках телефону: своя клавіатура, своє виділення тексту, свої жести. І тому стилізація обмеженіша, ніж у CSS для <input>: ви задаєте рамку, колір, відступи, але не перетворюєте поле на довільний веб-віджет.

Навіщо він потрібен

TextInput потрібен там, де відповідь — вільний текст, який людина має набрати, а не обрати з короткого списку:

  • ім’я, email, пароль;
  • назва й опис поїздки;
  • коментар, пошуковий запит, номер у вільному форматі.

Якщо варіанти заздалегідь відомі («Карпати / Південь / …») — краще Picker. Якщо відповідь «так/ні» — Switch або Checkbox. TextInput — коли без клавіатури не обійтися.

Мінімальний контрольований приклад

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

export function NameField() {
  const [name, setName] = useState('');

  return (
    <View>
      <Text>Ім’я</Text>
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Ваше ім’я"
        autoCapitalize="words"
      />
    </View>
  );
}

Що тут відбувається крок за кроком:

  1. У state лежить рядок name (спочатку порожній).
  2. value={name} каже полю: «покажи саме це».
  3. Коли людина друкує, викликається onChangeText з новим рядком.
  4. setName оновлює state → React перемальовує → у полі знову актуальний текст.

Label і placeholder

Placeholder — сірий підказковий текст всередині порожнього поля. Він зникає, щойно з’явився хоч один символ. Тому для форми з кількома полями майже завжди потрібен окремий label зверху («Назва поїздки», «Email»): інакше після заповнення людина не пам’ятає, що це за рядок.

У Nomad для цього є обгортка TextField: label + TextInput + текст помилки або hint.

Корисні props (розгорнуто)

value
string
Текст, який зараз має бути видно в полі. Якщо ви передаєте value, React Native вважає поле контрольованим: нативний control більше не «живе сам», а показує те, що ви йому дали з state / RHF.Практично: після кожного setState / field.onChange компонент перемальовується, і в рамці з’являється вже новий рядок. Якщо value передали, а оновлювати забули — поле залипне (друкуєте, а символи не з’являються або одразу зникають).
onChangeText
(text: string) => void
Колбек, який викликається на кожну зміну тексту (літера, backspace, вставка з буфера). На відміну від вебу, аргумент — одразу готовий рядок усього вмісту поля, а не event з event.target.value.Типове використання: onChangeText={setEmail} або в RHF onChangeText={onChange} (те, що дав Controller). Саме тут ви синхронізуєте UI зі state.
defaultValue
string
Початковий текст для неконтрольованого поля — тобто коли немає prop value, і нативний control сам тримає вміст. Зручно для разового демо, але для форми з валідацією майже завжди гірше: важче прочитати поточне значення, показати помилку й скинути форму.У зв’язці з React Hook Form і Zod курс тримається controlled-шляху (value + onChangeText), а defaultValue залишає для винятків.
placeholder
string
Сірий підказковий текст всередині поля, поки воно порожнє («you@example.com», «Назва поїздки»). Як тільки з’явився хоч один символ — placeholder зникає.Тому placeholder не замінює підпис поля (label) зверху: у довгій формі після заповнення людина вже не бачить, що це було «регіон» чи «місто». Label лишається завжди; placeholder лише підказує формат або приклад.
placeholderTextColor
color
Окремий колір для placeholder. Якщо його не задати, система обере дефолт, який у темній темі часто виглядає як «сіре на сірому» або навпаки занадто контрастний.У Nomad TextField бере placeholderTextColor з colors.textSecondary теми, щоб light/dark залишались читабельними без ручного підбору на кожному екрані.
editable
boolean
Чи можна змінювати текст. false — поле видно, але тап не відкриває клавіатуру і не дає редагувати (стан «тільки перегляд», прев’ю даних, тимчасово заблоковане поле під час submit).Це не те саме, що прибрати поле з екрана: layout лишається, людина розуміє «тут буде / тут лежить значення», просто зараз його не чіпають.
multiline
boolean
Увімкнути кілька рядків (аналог <textarea>). Потрібно для опису поїздки, коментаря, адреси з переносами.На Android текст часто «стрибає» по вертикалі всередині рамки: додайте textAlignVertical="top", щоб рядки починались зверху. Також задайте minHeight (або фіксовану висоту) у стилі — інакше поле може бути занадто низьким для зручного вводу.
maxLength
number
Жорстка стеля довжини на рівні UI: далі символи просто не вводяться. Корисно, щоб назва не роздулась до тисячі символів і щоб узгодити ліміт із бекендом «наперед».Важливо: maxLengthне замінює повідомлення валідації. Zod усе одно варто тримати (інше формулювання помилки, trim, мінімум символів). maxLength — захист від випадковості; Zod — зрозумілий фідбек і єдині правила.
secureTextEntry
boolean
Маскує введені символи (крапки / зірочки) — класичне поле пароля. Працює лише в однорядковому режимі: з multiline={true} документація прямо каже, що secure mode не підтримується.Окремо продумайте «показати пароль» (іконка ока): це зазвичай secureTextEntry={hide} + Pressable, а не магія самого TextInput.
keyboardType
enum
Яку розкладку програмної клавіатури показати системі: default, email-address (часто є @ і . під рукою), numeric / number-pad, phone-pad, url тощо.Це зручність, не валідація. Людина все одно може вставити з буфера «не email» у поле з email-address. Тому після вводу (або на submit) правила Zod лишаються обов’язковими.
autoCapitalize
'none' \| 'sentences' \| 'words' \| 'characters'
Чи підставляти великі літери автоматично: лише на початку речення, кожного слова, усіх символів, або ніколи (none).Для email, пароля, кодів підтвердження, нікнеймів майже завжди ставте none: інакше система зробить You@Example.com, і людина дивуватиметься, чому «логін не підходить» (хоча проблема в регістрі / автокапіталізації).
autoCorrect
boolean
Чи пропонувати автовиправлення й підказки словника. Для звичайної української/англійської нотатки — інколи корисно. Для email, токенів, номерів, нікнеймів — ставте false, щоб система не «виправляла» адресу на випадкове слово зі словника.
returnKeyType
enum
Текст/іконка кнопки Return на клавіатурі: next («далі»), done («готово»), send («надіслати»), search («пошук») тощо. Це підказка наміру для людини, а не автоматичний submit форми на всіх платформах однаково.Узгоджуйте з реальною поведінкою: якщо Return веде на наступне поле — next; якщо це останнє поле й відправка — send / done + onSubmitEditing.
onSubmitEditing
() => void
Колбек, коли натиснули Return на клавіатурі. Типові сценарії: перевести focus на наступне поле (ref.current?.focus()) або викликати handleSubmit на останньому полі.Не покладайте лише на цей колбек як єдиний спосіб відправити форму: частина користувачів завжди шукає велику кнопку «Створити» пальцем, а не Return.
blurOnSubmit
boolean
Чи зняти фокус (і часто сховати клавіатуру) після Return. Для ланцюжка «поле 1 → поле 2» зазвичай ставлять blurOnSubmit={false} на першому полі, щоб після Return одразу сфокусувати наступне без зайвого блимання клавіатури.На останньому полі навпаки часто залишають поведінку за замовчуванням / true, щоб після send клавіатура сховалась.
onBlur / onFocus
callbacks
onFocus — поле стало активним (клавіатура, курсор). onBlur — фокус пішов (тапнули в інше місце, наступне поле, сховали клавіатуру).У React Hook Form onBlur з Controller потрібен, якщо валідуєте в режимах на кшталт onBlur / onTouched: помилка з’являється не на кожну літеру, а коли людина пішла з поля. Навіть у mode: 'onSubmit' передавати onBlur корисно для узгодженості з RHF.

Демо: різні типи клавіатур

TSXTextInputTypes.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Pressable і Button — дії форми

Поля збирають дані. Кнопка збирає намір: «зберегти», «скасувати», «далі», «увійти». Без кнопки (або еквівалента) форма — лише набір значень, з якими нічого не сталося.

Pressable (основний інструмент курсу)

Pressable — компонент React Native, який робить будь-який вкладений вміст «натискабельним»: View + Text, іконка, ціла картка. Ми вже використовували його для карток і sticky CTA; у формах той самий інструмент стає кнопкою відправки.

Чому не «окремий HTML button»? Бо HTML немає. Ви самі малюєте вигляд (колір primary, скруглення, підпис) і вішаєте логіку на onPress.

Що дає Pressable у формі:

  1. onPress — головна дія (викликати handleSubmit, закрити екран, скинути поля).
  2. style={({ pressed }) => …} — функція стилю: поки палець на кнопці, pressed === true, можна трохи затемнити фон. Людина бачить, що тап спрацював.
  3. disabled — не реагувати на тапи (під час збереження, поки форма невалідна за бажанням дизайну).
  4. hitSlop (за потреби) — розширити зону тапу, якщо кнопка візуально маленька.
  5. onLongPress — довге утримання; для звичайного Submit майже не потрібен.
<Pressable
  onPress={handleSubmit(onValid)}
  disabled={submitting}
  style={({ pressed }) => [
    styles.btn,
    pressed && !submitting && styles.btnPressed,
    submitting && styles.btnDisabled,
  ]}
>
  <Text style={styles.btnText}>
    {submitting ? 'Зберігаємо…' : 'Створити'}
  </Text>
</Pressable>

Розбір цього фрагмента:

  • handleSubmit(onValid)не викликає onValid одразу. Спочатку React Hook Form (+ Zod) перевіряє поля; якщо є помилки, кнопка «ніби натиснулась», але збереження не відбувається, натомість з’являються errors під полями. (Повний розбір handleSubmit — у розділі RHF + Zod нижче; тут важливий лише зв’язок «кнопка → перевірка → дія».)
  • submitting — ваш прапорець «зараз виконуємо збереження» (навіть якщо це просто локальний addTrip без мережі). Поки true, disabled не дає натиснути двічі й створити дві однакові поїздки.
  • Підпис кнопки змінюється на «Зберігаємо…», щоб було видно прогрес без окремого спінера (спінер можна додати пізніше).

У Nomad компонент Button з @/shared/ui — це зручна обгортка: всередині той самий Pressable, ззовні — props label, variant (primary / secondary), кольори з теми. Так на екранах не копіюють одні й ті самі стилі кнопки.

Системний Button з react-native

У бібліотеці react-native є ще компонент з ім’ям Button. Він мінімалістичний: title, onPress, інколи color і disabled. Вигляд малює система: на iOS одна естетика, на Android — інша (Material-подібна). Ви майже не контролюєте скруглення, шрифт, відступи так, як у своїй дизайн-системі з токенами.

Тому:

  • для швидкого експерименту в playground — ок;
  • для Nomad і «справжнього» UI курсу — свій button на Pressable (як Button у shared/ui).

Не плутайте назви: «Button у shared» ≠ «Button з core React Native». У розмові команди краще казати «наш Button» / «Pressable-кнопка» vs «системний RN Button».

Що бачить користувач (і що ламається)

Успішний шлях: тап → коротка анімація pressed → (якщо все валідно) дані зберігаються / екран змінюється.

Невалідна форма: тап → поля підсвічуються помилками, екран не «мовчить». Якщо забути показати errors, людина подумає, що кнопка зламалась.

Подвійний тап: без disabled під час submit можна двічі додати одну поїздку. Навіть локально це псує список; з API — подвійний запит.

TSXFormPressable.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Switch — перемикач так / ні

Switch — вбудований компонент React Native, який показує бінарний стан у вигляді перемикача (як «Wi‑Fi увімкнено» в налаштуваннях телефону).

Коли обирати Switch, а не Checkbox

  • Switch — налаштування, що «живе» постійно: приватність, темна тема, «надсилати сповіщення». Очікування: посунув — одразу застосувалось (або одразу потрапило в state форми).
  • Checkbox — згода, пункт у списку опцій, «я маю план маршруту». Візуально ближче до «позначити галочкою».

Обидва тримають boolean. Різниця — UX-звичка і вигляд.

Базовий приклад

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

export function PrivateToggle() {
  const [isPrivate, setIsPrivate] = useState(false);

  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
      <Text style={{ flex: 1 }}>Приватна поїздка</Text>
      <Switch value={isPrivate} onValueChange={setIsPrivate} />
    </View>
  );
}

Props

value
boolean
Поточний стан перемикача: true — «увімкнено» (thumb праворуч / активний колір), false — «вимкнено». Як і в TextInput, для контрольованого режиму ви зобов’язані тримати це значення в state або RHF і оновлювати його з onValueChange.Якщо передати value={true} і ніколи не змінювати — Switch виглядатиме увімкненим, але жест користувача «не прилипне» до даних форми.
onValueChange
(value: boolean) => void
Викликається, коли людина завершила жест перемикання. Аргумент — уже новий boolean (не event). Сюди кладуть setIsPrivate або onChange з Controller.На відміну від слайдера, тут немає «проміжних» значень під час анімації: прийшов true або false — і це фінальний стан після жесту.
disabled
boolean
Забороняє зміну. Візуально Switch зазвичай стає тьмянішим; тапи ігноруються. Корисно під час submit («зачекайте, зберігаємо…») або коли опція недоступна через інше поле (наприклад, приватність вимкнена політикою акаунта).
trackColor
{ false?: color, true?: color }
Колір доріжки (фону «ковбаски») окремо для вимкненого й увімкненого стану. У світлій темі часто: вимкнено — нейтральний border/gray, увімкнено — primary бренду.Без налаштування отримаєте системні кольори, які можуть не збігатися з вашою темою Nomad / light-dark.
thumbColor
color
Колір кружечка (thumb), який їздить по доріжці. На Android без явного кольору thumb інколи зливається з треком. У курсових прикладах часто білий/onPrimary на кольоровому primary-треку.
ios_backgroundColor
color
Специфічно для iOS: колір фону треку, коли Switch вимкнений. Якщо його не задати, у темній темі вимкнений Switch інколи майже зливається з фоном картки.На Android цей prop ігнорується — там достатньо trackColor.false.

Демо

TSXSwitchDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Checkbox — галочка (@react-native-community/checkbox)

Checkbox (прапорець, «галочка») — контроль, де людина позначає опцію: «згодна / не згодна», «маю / не маю». Візуально це квадратик (або коло), у якому з’являється ✓.

На відміну від Switch (перемикач «увімкнено як налаштування»), checkbox у формах часто читається як пункт зі списку умов або підтвердження («я прочитав правила»). Обидва тримають boolean; відрізняється очікування людини й малюнок.

У core React Native немає одного готового Checkbox, який однаково виглядає на iOS і Android «з коробки». Тому в екосистемі ставлять пакет @react-native-community/checkbox. Альтернатива на майбутнє — намалювати свій: Pressable + іконка; для навчання community-пакет коротший шлях.

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

npx expo install @react-native-community/checkbox
Попередження про New Architecture. Під час expo install інколи з’являється текст на кшталт «does not support the New Architecture».New Architecture (нова архітектура React Native) — сучасніший спосіб, яким JavaScript-частина спілкується з нативним UI телефону. Деталі — в окремих статтях курсу про runtime; зараз достатньо знати: деякі старіші community-пакети ще не повністю оновлені під цей шлях, і Expo чесно попереджає.У навчальному Nomad на Expo SDK 54 @react-native-community/checkbox зазвичай ставить і працює в Expo Go для звичайних форм. Якщо в майбутньому пакет «відвалиться» на вашій версії SDK, запасний план простий: намалювати свій чекбокс — Pressable + іконка «порожній / з галочкою» і той самий value / onValueChange у state. Для продукту перед релізом перевірте issue-трекер пакета й реальні збірки iOS/Android.

Базовий приклад

import { useState } from 'react';
import { Text, View } from 'react-native';
import Checkbox from '@react-native-community/checkbox';

export function TermsBox() {
  const [accepted, setAccepted] = useState(false);

  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
      <Checkbox value={accepted} onValueChange={setAccepted} />
      <Text style={{ flex: 1 }}>Погоджуюсь із збереженням даних на пристрої</Text>
    </View>
  );
}

Типові props

value
boolean
Чи стоїть галочка зараз: true — позначено, false — порожній квадрат/коло. Це те саме «контрольоване boolean», що й у Switch, лише інша візуальна метафора.У формі з обов’язковою згодою default майже завжди false: людина має свідомо поставити галочку, а не отримати «вже погоджено» з коробки.
onValueChange
(value: boolean) => void
Колбек після тапу по чекбоксу. Нове значення приходить аргументом. Підключайте до setState або до onChange з RHF Controller — інакше галочка «не липне» або стрибає назад.
disabled
boolean
Забороняє зміну (наприклад, поки не прочитали текст угоди, або під час відправки). Зазвичай супроводжується приглушеним виглядом; точна відрисовка залежить від платформи й версії пакета.
tintColors
{ true?: color, false?: color }
Переважно Android: колір акценту, коли галочка стоїть (true), і колір рамки/контролу, коли порожньо (false). Підтягніть true до colors.primary теми, щоб чекбокс не виглядав «системно-зеленим» посеред синього бренду.
onCheckColor / onFillColor / onTintColor
color (iOS)
Тонше фарбування на iOS (props community-пакета):
  • onCheckColor — колір самої «галочки» (ліній ✓);
  • onFillColor — заливка квадрата, коли обрано;
  • onTintColor — колір рамки в активному стані.
Без них iOS візьме системні відтінки, які знову ж можуть розійтися з вашою палітрою light/dark.
boxType
'circle' \| 'square' (iOS)
Форма контейнера на iOS: круглий або квадратний чекбокс. На Android форма зазвичай квадратна за гайдлайнами Material; boxType там не дає того самого ефекту.Обирайте square для «згоди з умовами» (звичніше в формах) і circle лише якщо дизайн свідомо хоче radio-like вигляд — але пам’ятайте: це все одно не radio group (один з багатьох).

Обов’язкова згода в Zod

Часто треба не просто boolean, а саме true:

acceptedLocalSave: z.literal(true, {
  error: 'Підтвердіть, що дані поки зберігаються лише на пристрої',
});

Тут важлива відмінність між «будь-який boolean» і «саме true».

  • z.boolean() приймає і true, і false — підходить для опції «є план маршруту», де «ні» — нормальна відповідь.
  • z.literal(true) приймає лише true. Якщо в формі false (галочку не поставили) — схема невалідна, у errors з’являється ваше повідомлення під рядком згоди.

У defaultValues для такої згоди свідомо ставлять false (або «ще не true»), щоб галочка не була увімкнена «з коробки». Інакше юридично й UX-ом ви «підписали» за людину.

У Nomad два checkbox поруч ілюструють обидва випадки:

  1. hasItineraryz.boolean(), опційно, без обов’язку;
  2. acceptedLocalSavez.literal(true), без галочки submit не пройде.

Slider — число з діапазону (@react-native-community/slider)

Slider (повзунок) — контроль для вибору числа між мінімумом і максимумом без набору з клавіатури: палець тягне «ручку» вздовж лінії, значення змінюється.

У самій бібліотеці react-native слайдера як стабільного core-API для курсу ми не використовуємо — ставлять пакет спільноти @react-native-community/slider (та сама історія, що з Picker/Checkbox: окремий репозиторій, npx expo install).

Коли доречно

Слайдер хороший, коли важливий порядок величини, а не «довільний рядок цифр»:

  • орієнтовна кількість днів (1–14);
  • гучність, яскравість у власних налаштуваннях;
  • рейтинг 1–10;
  • бюджет «приблизно» (інколи два слайдери «від–до»).

Коли не доречно

  • точний номер телефону, email — це не діапазон;
  • сума з копійками для бухгалтерії — людина хоче набрати 199.99, а не влучити повзунком;
  • будь-що, де помилка на одиницю критична й варіантів мало — інколи краще Picker з готовими числами.

Правило: якщо на вебі ви б поставили type="range" — думайте про Slider; якщо type="number" з точним вводом — TextInput + keyboardType.

Встановлення

npx expo install @react-native-community/slider

Базовий приклад

import { useState } from 'react';
import { Text, View } from 'react-native';
import Slider from '@react-native-community/slider';

export function DaysSlider() {
  const [days, setDays] = useState(3);

  return (
    <View>
      <Text>Тривалість: {days} дн.</Text>
      <Slider
        minimumValue={1}
        maximumValue={14}
        step={1}
        value={days}
        onValueChange={setDays}
      />
    </View>
  );
}

Props

value
number
Поточна позиція повзунка числовим значенням (наприклад 3 дні). Має лежати між minimumValue і maximumValue. Для контрольованого режиму тримайте число в state / RHF і показуйте його текстом поряд («Тривалість: 3 дні») — сам Slider не малює підпис значення.
onValueChange
(value: number) => void
Викликається багаторазово під час перетягування, щойно значення змінилось (з урахуванням step). Підходить, щоб одразу оновлювати підпис «3 дні → 4 дні» і писати в RHF.Мінус: якщо на кожну зміну ганяти важку логіку (мережа, важкий перерахунок) — буде зайве навантаження. Тоді дивіться onSlidingComplete.
onSlidingComplete
(value: number) => void
Спрацьовує один раз, коли палець відпустили після жесту. Зручно для «дорогих» побічних ефектів: аналітика, запит, запис у storage.Частий патерн: у onValueChange оновлювати лише локальний UI-стан, а в onSlidingComplete — комітити в форму / бекенд. У простій формі Nomad достатньо onValueChange → RHF.
minimumValue / maximumValue
number
Ліва й права межі діапазону. Наприклад 1 і 14 для «від одного до двох тижнів». Якщо value вийде за межі (через баг у коді), поведінка залежить від платформи — краще завжди клампити значення в схемі Zod (z.number().min(1).max(14)).
step
number
Крок квантування. step={1} — лише цілі числа (дні, кількість місць). step={0.5} — половинки. Без step значення може бути «сирим» float з багатьма знаками після коми — для UI «дні» це зазвичай погано.
minimumTrackTintColor / maximumTrackTintColor / thumbTintColor
color
Три кольори візуалу:
  • minimumTrackTintColor — уже «пройдена» частина треку (зліва від thumb), часто primary;
  • maximumTrackTintColor — ще «порожня» частина справа, часто border / сірий;
  • thumbTintColor — кружечок, яким тягнуть.
Без них слайдер виглядає системно й може випадати з light/dark теми застосунку.
disabled
boolean
Повзунок не рухається. Корисно, коли діапазон залежить від іншої опції («спочатку оберіть тип поїздки») або йде submit.

У формі Nomad слайдер відповідає полю plannedDays: «орієнтовно скільки днів триватиме поїздка» (1–14). Це навчально відокремлено від точних startDate / endDate у date picker.

Навіщо два механізми? Дати відповідають на «коли саме». Слайдер — на «як довго я зараз думаю в голові», навіть якщо календарний діапазон ще рухають. У реальному продукті їх часто синхронізують (змінив дати → перерахував дні). У курсі лишаємо незалежними, щоб ви побачили Slider як окремий тип контролю, а не «дубль календаря».


Picker — вибір зі списку (@react-native-picker/picker)

Picker — контроль «обери один варіант із заздалегідь відомого списку».

На вебі ту саму задачу вирішує <select>: розкрив список → тапнув рядок → значення зафіксувалось. У React Native немає вбудованого універсального <select> у core. Раніше picker жив ближче до самої бібліотеки; зараз у проєктах ставлять пакет спільноти @react-native-picker/picker.

Що отримує користувач: не друкує «Карпати» вручну, а обирає з вашого списку — менше помилок, однакові значення в даних.

Коли Picker, а не TextInput

Picker доречний, коли варіанти відомі наперед і їх небагато (орієнтир — до одного-двох десятків):

  • регіон із фіксованого набору («Карпати», «Південь», …);
  • валюта, мова інтерфейсу, статус заявки («чернетка / опубліковано»);
  • тип квитка, одиниця виміру.

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

Якщо варіантів багато і потрібен пошук — часто роблять окремий екран «оберіть зі списку» з рядком пошуку (комбінація навігації + FlatList; природно ляже після модуля навігації). Для 5–15 пунктів без пошуку Picker — найпростіший шлях.

Встановлення

npx expo install @react-native-picker/picker

Базовий приклад

import { useState } from 'react';
import { View } from 'react-native';
import { Picker } from '@react-native-picker/picker';

const REGIONS = ['Карпати', 'Захід', 'Південь', 'Центр', 'Схід', 'Інше'] as const;

export function RegionPicker() {
  const [region, setRegion] = useState<(typeof REGIONS)[number]>('Карпати');

  return (
    <View>
      <Picker selectedValue={region} onValueChange={(v) => setRegion(v)}>
        {REGIONS.map((r) => (
          <Picker.Item key={r} label={r} value={r} />
        ))}
      </Picker>
    </View>
  );
}

Props

selectedValue
string | number | …
Який пункт зараз обраний. Значення має точно збігатися з одним із value у дочірніх Picker.Item. Якщо в state лежить рядок, якого немає в списку (старі дані, опечатка, інша локаль) — UI може показати порожньо або перший пункт, а дані форми будуть «криві».Тому для регіонів у Nomad і список TRIP_REGIONS, і z.enum(TRIP_REGIONS) будуються з одного джерела констант.
onValueChange
(itemValue, itemIndex) => void
Колбек після вибору. Перший аргумент — value обраного item (те, що піде в state), другий — індекс у списку (рідше потрібен). У RHF зазвичай достатньо onValueChange={onChange}.На iOS колбек може спрацювати вже під час прокрутки барабана (залежно від версії / UX) — перевіряйте на пристрої, якщо кожна зміна дорога.
enabled
boolean
Чи можна змінювати вибір. false — picker «заморожений» (наприклад, регіон фіксується після створення поїздки). Зверніть увагу: prop називається enabled, а не disabled, на відміну від багатьох інших контролів RN.
mode
'dialog' \| 'dropdown' (Android)
Лише Android: як показати варіанти — у діалозі поверх екрана (dialog) чи як випадаючий список біля поля (dropdown). На iOS цей prop не перетворює wheel на HTML-select; там інша модель UI.Якщо дизайн «як combobox на Material» — дивіться dropdown і тестуйте на реальних щільностях екрана.
Picker.Item
child component
Один рядок списку. label — те, що читає людина («Карпати»). value — те, що потрапляє в selectedValue / форму (може збігатися з label або бути кодом на кшталт carpathians).Додатково на частині платформ є color для тексту item — корисно в dark theme, щоб білий текст не зник на білому колесі. Не змішуйте в одному Picker items з різними типами value (string vs number) без потреби — ускладнює схему Zod.

На iPhone Picker часто виглядає як барабан (wheel): кілька рядків списку, поточний варіант по центру, сусідні — трохи блідіші. Такий UI займає багато висоти (десятки пікселів), тому в формі його або кладуть у контейнер із явною minHeight, або відкривають у Modal / bottom sheet, а в рядку форми показують лише поточний label («Карпати ▾»).

Якщо просто вставити Picker між двома TextInput без запасу по висоті, сусідні поля «поїдуть», а на маленькому екрані частина барабана обріжеться. Перевіряйте на SE / mini, не лише на Pro Max.

У Zod для значень з Picker зручно z.enum([...]) (або z.union літералів): схема приймає лише рядки з білого списку. Якщо колись у state потрапить "Карпатти" або старий код регіону, якого вже немає в UI, submit не пройде, і ви побачите помилку — замість тихого запису сміття в список поїздок.

Ідеально, коли масив для Picker.Item і аргумент z.enumодна константа (як TRIP_REGIONS у Nomad). Тоді додати регіон = змінити одне місце.


Date picker — системна дата

Date picker — UI для вибору дати (і/або часу) у стилі операційної системи. Пакет для Expo SDK 54: @react-native-community/datetimepicker.

npx expo install @react-native-community/datetimepicker

Чому не TextInput «12.03.2026»

Теоретично можна попросити людину набрати дату руками. На практиці це майже завжди гірший UX:

  1. Локалі. Хтось звик до ДД.ММ.РРРР, хтось до ММ/ДД/РРРР, хтось до ISO РРРР-ММ-ДД. Одна й та сама «03/04/2026» — березень чи квітень?
  2. Помилки друку. Зайва крапка, двозначний рік, «31 лютого» — ви витратите час на парсер і повідомлення, які системний календар уже розв’язав.
  3. Timezone і «північ». Рядок без часу перетворюється на Date по-різному в JS залежно від формату й середовища; легко отримати «день раніше» на сервері.
  4. Звичка. На телефоні люди роками обирають дату барабаном / календарем у налаштуваннях і інших застосунках. Ламати цей патерн у вашій формі — зайве тертя.

Тому в state тримаємо об’єкт Date (або ISO-рядок, який ви самі стабільно серіалізуєте), а на екрані — системний picker + окрема функція форматування для підпису («12–14 бер. 2026»).

iOS vs Android (поведінка)

На Android date picker зазвичай відкривається як системний діалог поверх застосунку. Користувач обирає день у календарі (або крутить значення — залежить від версії / display) і натискає OK або скасовує.

У колбеку onChange приходить event і опційна selectedDate. Важливо дивитися event.type:

  • set — людина підтвердила вибір; беріть selectedDate і записуйте в state / RHF;
  • dismissed — закрила діалог без підтвердження; не перезаписуйте дату «на всяк випадок».

Після будь-якої з цих подій діалог сам зникає, але ваш React-стан open треба скинути в false, інакше наступного разу picker може повестися дивно (подвійне відкриття / «залипший» прапорець).

Зв’язок із формою

Розведіть два шари:

  1. Дані форми — тип Date (або пара startDate / endDate) у RHF. Саме їх валідує Zod (z.date(), refine «кінець ≥ початок»).
  2. Підпис для людини — рядок на кшталт «12–14 бер. 2026», який не є джерелом правди. Його будує окрема функція форматування після успішного submit (у Nomad — formatTripDateLabel), коли збираєте об’єкт Trip для списку.

Якщо зберігати в формі вже відформатований рядок, ви знову повертаєтесь до парсингу й порівняння дат «як текстів». Тримайте Date до останнього моменту, рядок — лише для UI картки.


Клавіатура: щоб форма лишалась керованою

Що відбувається

  1. Людина тапає TextInput.
  2. ОС показує soft keyboard.
  3. Видима висота контенту зменшується.
  4. Нижні поля й Submit можуть опинитися під клавіатурою.

Інструменти

KeyboardAvoidingView
component
Контейнер, який реагує на появу програмної клавіатури: піднімає, стискає або додає відступ своєму вмісту, щоб нижні поля й кнопка Submit не опинились «під» клавіатурою.Prop behavior задає стратегію. На iOS найчастіше ставлять padding (знизу з’являється padding ≈ висоті клавіатури). На Android інколи допомагає height, інколи padding, інколи система й так ресайзить window (залежить від windowSoftInputMode / edge-to-edge). Немає одного значення «на всі телефони світу» — перевіряйте на реальному пристрої.KeyboardAvoidingView має сенс, коли в дереві є TextInput. Для екрана лише зі Switch/Slider він майже нічого не змінить.
keyboardVerticalOffset
number
Додаткові пікселі зсуву понад те, що KAV порахував сам. Потрібні, коли над формою є кастомний header, sticky-шапка, відступ safe area, який KAV «не бачить» у своєму розрахунку.Симптом «майже дістало, але кнопка ще наполовину під клавіатурою» часто лікується саме offset (наприклад 8–64), а не новою бібліотекою. Підкручуйте на симуляторі з різною висотою екрана.
ScrollView
component
Вертикальний скрол умісту форми. Потрібен з двох незалежних причин:
  1. Форма довша за екран (багато контролів) — без скролу нижні поля недосяжні навіть без клавіатури.
  2. З клавіатурою видима область коротшає — скрол дає дістатись Submit, навіть якщо KAV не ідеальний.
На ScrollView форми майже завжди ставлять keyboardShouldPersistTaps (див. нижче) і часто contentContainerStyle з paddingBottom, щоб остання кнопка не липла до краю.
keyboardShouldPersistTaps
'never' \| 'always' \| 'handled'
Що робити, коли клавіатура відкрита, а людина тапає не по TextInput (наприклад, по кнопці «Створити»).
  • never (часто дефолт) — перший тап лише ховає клавіатуру, onPress кнопки не викликається. Здається, що «кнопка зламана».
  • handled — якщо тап обробив дочірній touchable (кнопка), клавіатура може лишитись / поведінка узгоджена, і onPress спрацьовує з першого разу. Це типовий вибір для форм.
  • always — тапи проходять ще агресивніше; використовуйте рідше, коли розумієте побічні ефекти.
keyboardDismissMode
'none' \| 'on-drag' \| 'interactive'
Чи ховати клавіатуру жестом скролу. on-drag — почали тягнути список/форму пальцем вниз/вгору, клавіатура тікає. Зручно, коли хочете швидко побачити нижню частину екрана.interactive (переважно iOS) дозволяє «прив’язати» ховання клавіатури до жесту більш плавно. none — скрол клавіатуру сам не чіпає (тоді лишаються Return, тап поза полем, Keyboard.dismiss).
Keyboard.dismiss()
API з react-native
Функція модуля Keyboard: програмно сховати soft keyboard. Викликають після успішного submit, по тапу на «порожній» фон (Pressable обгортка), інколи після переходу на наступний екран.Це не заміна KAV: dismiss лише ховає клавіатуру, а KAV + ScrollView відповідають за те, щоб під час вводу все ще було видно.

Типове дерево екрана форми

SafeArea / Screen          ← не лізе під чубчик і home-індикатор
  └── KeyboardAvoidingView (flex: 1)
        └── ScrollView (flex: 1, keyboardShouldPersistTaps="handled")
              └── поля (TextInput, Switch, Picker, …)
              └── кнопки Submit / Скасувати

Чому саме так:

  1. flex: 1 на KAV і ScrollView — щоб колонка зайняла всю висоту між safe area, а не стиснулась «по контенту» у верхівці екрана. Без цього скрол і уникнення клавіатури часто «не працюють».
  2. Спочатку KAV, всередині ScrollView — KAV змінює доступну висоту, ScrollView дає прокрутити те, що лишилось.
  3. Кнопки всередині скролу (або sticky footer з окремим продуманим offset) — на довгій формі Submit має бути досяжний. У Nomad кнопки внизу CreateTripForm всередині ScrollView.
Прев’ю на сайті курсу не відтворює soft keyboard iPhone/Android один в один. Остаточну перевірку «чи видно Submit» робіть у Expo Go або симуляторі, з фокусом у нижньому TextInput.

Валідація: навіщо правила окремо від UI

Валідація (validation) — відповідь на питання: «чи можна вже зараз прийняти ці дані й іти далі?» Наприклад: назва не порожня, email схожий на email, дата кінця не раніше початку, галочка згоди стоїть.

Навіщо взагалі перевіряти на телефоні

Уявіть, що кнопки «Створити» немає перевірки. Людина залишає порожню назву, тисне — і в списку з’являється картка без заголовка, або застосунок падає, або «нічого не відбувається». Усі три варіанти гірші за червоний текст «Назва має містити щонайменше 2 символи» прямо під полем.

Перевірка на телефоні (клієнтська) дає:

  • миттєвий фідбек без мережі;
  • менше випадкових порожніх записів у локальному стейті;
  • зрозумілі повідомлення вашою мовою UI.

Коли з’явиться сервер (API), з’явиться другий рівень: навіть якщо хтось обійде UI, сервер не прийме сміття. Клієнт не замінює сервер — він допомагає чесній людині не помилитись.

РівеньХто виконуєНавіщо
Клієнт (телефон)Zod + React Hook Form у цій статтіШвидкий фідбек, зручність
СерверAPI (пізніше в курсі)Справжній захист і бізнес-правила

Сьогодні свідомо лишаємось на клієнті: зберігаємо поїздку в пам’яті застосунку (TripsProvider), без HTTP.

Як показувати помилку (UX)

Погано: один Alert.alert('Помилка') на всі поля — неясно, що виправити.

Добре:

  1. Підпис поля (label) лишається.
  2. Рамка поля може стати кольору danger.
  3. Під полем — коротке українське речення з Zod (errors.title?.message).
  4. Після першого невдалого submit подальші виправлення одразу прибирають помилку (reValidateMode: 'onChange').

Опційний hint («Мінімум 10 символів») показують, коли помилки ще немає — це підказка, не покарання.


React Hook Form + Zod

Цей розділ з’єднує UI-контроли (все, що вище) з правилами і submit. Якщо контролі — «руки й очі» форми, то RHF + Zod — її «пам’ять і суддя».

Яку біль знімаємо

Без бібліотек типовий новачок пише:

const [title, setTitle] = useState('');
const [region, setRegion] = useState('Карпати');
// … ще 8 useState
const [errorTitle, setErrorTitle] = useState('');
// … ще купа error-стейтів

const onPress = () => {
  if (title.trim().length < 2) {
    setErrorTitle('…');
    return;
  }
  // ще if-и…
};

На п’яти полях це терпимо. На екрані з датами, слайдером, двома галочками й picker — легко заплутатись: десь забули скинути помилку, десь правило в UI і «на бекенді» роз’їхались, TypeScript не знає форми даних після submit.

React Hook Form забирає на себе values + errors + момент submit.
Zod описує правила одним об’єктом-схемою і дає TypeScript-тип «безкоштовно».
zodResolver зшиває їх: «на submit прожени values через цю схему».

React Hook Form простими словами

React Hook Form (часто скорочують RHF) — бібліотека для React, яка:

  1. Тримає поточні значення всіх полів форми.
  2. Тримає помилки після перевірки.
  3. Дає handleSubmit: «спочатку перевір, потім виклич мій onValid».
  4. Намагається менше перемальовувати весь екран на кожну літеру, ніж наївний useState на кожне поле (деталь оптимізації не обов’язково розуміти на старті — важливий API).

Головний хук: useForm. З нього ви дістаєте control, handleSubmit, formState: { errors }, watch тощо.

Zod простими словами

Zod — бібліотека, де ви описуєте форму даних як схему:

const schema = z.object({
  title: z.string().trim().min(2, 'Назва занадто коротка'),
});

На runtime (під час роботи застосунку) можна зробити schema.safeParse(data) і дізнатись: ок чи список помилок.
На етапі TypeScript пишете type FormValues = z.infer<typeof schema> — і тип полів збігається зі схемою, без ручного дублювання інтерфейсу.

Повідомлення в .min(2, '…') — ті самі рядки, які потім побачить користувач під полем (якщо ви їх показуєте з errors).

zodResolver — місток між ними

RHF не вміє «з коробки» читати Zod-схеми. Пакет @hookform/resolvers дає функцію zodResolver(schema). Ви передаєте її в useForm({ resolver: … }), і на submit RHF:

  1. збирає поточні values;
  2. віддає їх resolver;
  3. resolver ганяє Zod;
  4. при помилках заповнює formState.errors;
  5. при успіху викликає ваш onValid(data).

Без resolver довелось би вручну кликати schema.safeParse у кнопці — можливо, але тоді ви самі дублюєте те, що RHF уже вміє з errors і focus.

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

actor User
participant "Control UI" as UI
participant "RHF Controller" as C
participant "useForm" as F
participant "zodResolver" as R
participant "Zod schema" as Z

User -> UI: змінює значення
UI -> C: onChange / onChangeText
C -> F: field.onChange
User -> F: handleSubmit
F -> R: values
R -> Z: safeParse
alt invalid
  Z --> F: errors
  F --> UI: error message
else valid
  Z --> F: onValid(data)
end

@enduml

Controller — як підключити будь-який контроль

Controller — компонент React Hook Form. Він каже: «я відповідаю за одне поле форми з іменем name». Усередині render ви малюєте свій UI і підставляєте з field три речі:

З fieldНавіщо
valueщо показати в контролі зараз
onChangeяк повідомити RHF про нове значення
onBlurяк повідомити, що поле втратило фокус

Prop control — об’єкт з useForm(), «пульт» усієї форми. Без нього Controller не знає, до якої форми належить поле.

Prop name — рядок-ключ у values і в errors ("title", "isPrivate"). Той самий ключ має бути в defaultValues і в Zod-схемі.

Різні контроли лише по-різному називають props значення/зміни — ідея та сама:

// TextInput: value + onChangeText
<Controller
  control={control}
  name="title"
  render={({ field: { onChange, onBlur, value } }) => (
    <TextInput value={value} onChangeText={onChange} onBlur={onBlur} />
  )}
/>

// Switch: value + onValueChange (boolean)
<Controller
  control={control}
  name="isPrivate"
  render={({ field: { onChange, value } }) => (
    <Switch value={value} onValueChange={onChange} />
  )}
/>

// Slider: value + onValueChange (number)
<Controller
  control={control}
  name="plannedDays"
  render={({ field: { onChange, value } }) => (
    <Slider
      value={value}
      onValueChange={onChange}
      minimumValue={1}
      maximumValue={14}
      step={1}
    />
  )}
/>

// Picker: selectedValue + onValueChange
<Controller
  control={control}
  name="region"
  render={({ field: { onChange, value } }) => (
    <Picker selectedValue={value} onValueChange={onChange}>
      {/* Picker.Item … */}
    </Picker>
  )}
/>

Помилку під полем читаєте поруч, не всередині Controller обов’язково:

{errors.title ? <Text>{errors.title.message}</Text> : null}

У Nomad це вже загорнуто в TextField / FormRow (error={errors.title?.message}).

useForm — що налаштовуємо

resolver
zodResolver(schema)
Хто перевіряє values перед успішним submit. zodResolver з пакета @hookform/resolvers бере вашу Zod-схему, ганяє safeParse (внутрішньо) і перетворює помилки Zod на формат formState.errors, зрозумілий RHF.Без resolver лишаються лише rules у кожному Controller — для великої форми це швидко роз’їжджається. З resolver одне місце правди для правил: файл схеми.
defaultValues
object
Початкові значення кожного поля форми в момент useForm. Для controlled-інпутів це критично: якщо ключа немає, value може бути undefined, і React видасть warning / поле поводитиметься дивно.Приклади: порожні рядки '' для TextInput, false для Switch/Checkbox, число для Slider, перший елемент enum для Picker, new Date() для дат. Для обов’язкової згоди z.literal(true) у default свідомо ставлять false, щоб галочку поставили руками.
mode
'onSubmit' \| 'onChange' \| 'onBlur' \| 'onTouched' \| 'all'
Коли вперше запускати валідацію.
  • onSubmit (часто найспокійніший UX): помилки з’являються після натискання «Створити», а не коли людина ще друкує першу літеру.
  • onChange — перевірка на кожну зміну (агресивно, інколи втомлює).
  • onBlur — коли пішли з поля.
У Nomad для створення поїздки обрано onSubmit + reValidateMode: 'onChange': спочатку не кричати, після першої спроби — допомагати виправляти одразу.
reValidateMode
'onChange' \| 'onBlur' \| 'onSubmit'
Після того, як форма вже була invalid (або вже сабмітилась), коли перераховувати помилки знову. onChange означає: виправили текст — помилка під полем зникає без повторного натискання Submit. Це відчувається «живо» і зменшує роздратування.
handleSubmit(onValid, onInvalid?)
function
Функція-обгортка для кнопки: onPress={handleSubmit(onValid)}. Спочатку RHF (+ resolver) перевіряє всі поля. Якщо ок — викликає onValid(data) уже з провалідованими даними (і з типами з Zod). Якщо ні — заповнює errors, фокус на помилку (за налаштуваннями), onValidне викликається.Опційний другий аргумент onInvalid рідко потрібен у простих формах; інколи використовують для аналітики «скільки разів провалили submit».
formState.errors
object
Словник помилок по іменах полів. Для поля title читаєте errors.title?.message — це рядок, який ви самі задали в Zod ('Назва має містити…'). Саме його показують під TextField / FormRow.Поки поле валідне (або ще не валідували) — відповідного ключа немає або message порожній. Не плутайте з exceptions: невалідна форма — нормальний UX-стан, не crash.
watch(name?)
value | values
Підписка на поточні values без submit. watch('startDate') повертає дату початку, щоб передати її в minimumDate для поля кінця. watch() без аргументів — увесь об’єкт (обережно: більше ререндерів).watch зручний для UI, що залежить від інших полів (показати блок, обмежити slider, порахувати підсумок). Для submit-логіки все одно покладайтесь на handleSubmit + схему.

Демо UX помилок (без RHF у preview-host)

TSXFormErrorsDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Антипатерни (і чому вони болять)

1. Усе через TextInput

Регіон набирають руками, «так/ні» пишуть словами «так». Наслідки: помилки друку, різний регістр, неможливість нормально агрегувати дані («Карпати» vs «карпати»). Обирайте контроль під тип відповіді.

2. Лише Alert для помилок

Один діалог «Щось не так» змушує людину згадувати всю форму. Правило: помилка біля поля, яке її викликало; Alert — лише для рідкісних глобальних збоїв (немає місця на диску тощо).

3. Правила тільки в onPress без схеми

Купка if у кнопці дублюється, коли з’явиться другий екран редагування. Схема Zod — одне місце; її ж можна ганяти в тестах без UI.

4. Форма без ScrollView

На SE / маленькому Android з відкритою клавіатурою нижня кнопка недосяжна. Навіть «коротка» форма з п’ятьма полями часто потребує скролу.

5. Забутий keyboardShouldPersistTaps="handled"

Симптом: «перший тап по Submit нічого не робить». Насправді перший тап лише ховає клавіатуру. Користувач думає, що застосунок глючить.

6. Email з autoCapitalize="sentences"

Система робить велику літеру на початку — і раптом «Email вже зайнятий» / «невірний логін» через регістр. Для email, пароля, кодів — autoCapitalize="none" і зазвичай autoCorrect={false}.

7. Дата як вільний текст

Парсери, локалі, timezone — див. розділ про date picker. Не відтворюйте цей біль без вагомої причини.

8. «У нас Zod, отже безпечно»

Zod на телефоні бачить лише те, що пройшло через ваш UI. Зловмисник може надіслати запит на API в обхід застосунку. Клієнтська схема — для UX; серверна перевірка — для безпеки (коли з’явиться API).


Міні-проєкт: «Реєстрація на подію»

Мета

Окремий застосунок поза Nomad. Нижче — шлях від А до Я: команди, структура, повний код файлів. Можна копіювати й запускати без «домалюйте самі».

Сюжет: реєстрація на умовну подію. Два стани без Router:

  1. Форма (усі типи контролів + RHF + Zod).
  2. Успіх (підсумок + «Ще раз»).

Що має вийти

ПолеКонтрол
Ім’яTextInput
EmailTextInput + keyboardType="email-address"
Місць 1–5Slider
Тип квиткаPicker
НагадуванняSwitch
Згода з правиламиCheckbox + z.literal(true)
КоментарTextInput multiline
SubmitPressable + handleSubmit

Обов’язково: SafeAreaProviderKeyboardAvoidingViewScrollView + 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 і скопіюйте цілком).

Крок 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. Ручна перевірка

  1. npx expo start → Expo Go.
  2. Одразу «Зареєструватись» → помилки під полями.
  3. Коректні дані + галочка → екран успіху.
  4. «Ще раз» → порожня форма.
  5. Фокус у коментарі на малому екрані — кнопка доступна скролом.

Критерій «готово»

  • Проєкт створено командами кроку 1
  • App.tsxповний код з collapsible (усі контроли)
  • Порожній submit показує помилки під полями
  • Без згоди немає екрана успіху
  • Успіх + «Ще раз»
  • KAV + ScrollView + keyboardShouldPersistTaps
Zod 3 vs 4. У прикладі синтаксис ближчий до Zod 4 (error у literal). На Zod 3 підставте API повідомлень з docs вашої major-версії — ідея схеми та сама.

Nomad: форма створення поїздки

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

До цієї статті кнопка «Нова поїздка» або нічого не робила, або лише логувала в консоль. Тепер вона відкриває екран форми. Людина заповнює назву, регіон зі списку, дати, опис, тривалість повзунком, за бажанням позначає «приватна» / «є план», підтверджує локальне збереження — і після успіху повертається на стрічку, де нова картка лежить зверху.

На картці видно не лише title: мітки «приватна», «є план», орієнтовну кількість днів — щоб було видно, що boolean/slider реально потрапили в модель Trip.

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

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

  • ThemeProvider, чіпи light/dark/system;
  • Screen, AppText, shared Button;
  • домашня стрічка на FlashList + горизонтальні місця;
  • pull-to-refresh, sticky CTA «Нова поїздка»;
  • mock-поїздки й mock-місця.

Що з’являється саме в цій статті:

ШматокРоль
Пакети RHF, Zod, picker, slider, checkbox, datetimepickerзалежності форми
createTripSchemaправила Zod
TripsProvider / useTripsспільний список між home і формою
TextField, FormRow, DateFieldUI-обгортки контролів
CreateTripFormуся форма + Controller
app/create-trip.tsxекран-маршрут
розширення типу TripisPrivate, 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

Повний знімок проєкту

Перевірка

  1. Чіпи теми на home працюють.
  2. Списки поїздок і місць — як раніше.
  3. «Нова поїздка» → форма з TextInput, Picker, датами, Slider, Switch, Checkbox.
  4. Порожній submit / без згоди → помилки під полями.
  5. Успіх → картка зверху; мітки приватності / плану.
  6. 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.

TSXNomadCreateTrip.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


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

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

  1. Своїми словами пояснити, коли Switch, а коли Checkbox.
  2. Зібрати TextInput + Switch + Pressable без RHF.
  3. Пояснити, навіщо value + колбек зміни на будь-якому контролі.

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

  1. Міні-проєкт «Реєстрація на подію» з RHF + Zod і щонайменше чотирма типами контролів.
  2. У Nomad додати Picker.Item «Закордон» у TRIP_REGIONS і прогнати схему.
  3. Ланцюжок focus між двома TextInput через returnKeyType="next".

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

  1. Порівняти mode: 'onBlur' і onSubmit на формі з 6+ полями — короткий висновок.
  2. Unit-тести на createTripSchema.safeParse (без UI).
  3. Нотатка: Checkbox і New Architecture — що каже warning Expo і який план Б (Pressable + іконка).

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


Що далі

Тепер у вас є словник контролів і спосіб зв’язати їх правилами. Далі — основи Expo Router: тека app/, layout, Tabs і Stack, Link і useRouter. Екран create-trip уже натякає на стопку екранів; наступна стаття робить навігацію свідомою моделлю всього застосунку (вкладки «Поїздки / Місця / Ще»).

TextInput · Pressable · Button · Switch · Slider · Picker · Checkbox · DateTimePicker (Expo) · React Hook Form · Zod · Nomad

Copyright © 2026