React Native

Структура проєкту та перші UI-примітиви

Як організувати Expo-проєкт для зростання — папки app і src, design tokens, Screen, AppText, Button, платформні файли

Структура проєкту та перші UI-примітиви

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

У попередньому матеріалі проєкт запускається: є Nomad, Metro, екран у Expo Go. Шаблон create-expo-app уже містить файли — інколи в корені, інколи з папкою app/ для навігації. Якщо лишити все «як вийшло з генератора» і далі кидати нові екрани в одну купу, через кілька тижнів з’являться типові проблеми:

  • незрозуміло, де лежить логіка поїздок, а де — кнопки загального вигляду;
  • кольори й відступи скопичені в десятках файлів (#3B82F6, padding: 16 усюди);
  • той самий стиль кнопки скопійовано тричі з дрібними відмінностями;
  • імпорти виглядають як ../../../components/Button.

Мета цієї статті — закласти просту, розжовану структуру, якої вистачить на весь курс Nomad, без надмірної «ентерпрайз-архітектури» в перший тиждень.

Головна думка. Папка app/ відповідає за маршрути (які екрани є в застосунку). Папка src/ — за зміст (фічі, спільний UI, тема). Екран у app/ має бути тонким: зібрати готові шматки з src/, а не містити всю бізнес-логіку в одному файлі на 800 рядків.

Після статті:

  1. У Nomad з’явиться узгоджена структура папок.
  2. З’являться design tokens (словник кольорів, відступів, шрифтів).
  3. З’являться примітиви Screen, AppText, Button.
  4. Буде зрозуміло, навіщо файли .ios.tsx / .android.tsx.
  5. Міні-проєкт «Візитка» закріпить ідею окремо від Nomad.
  6. Git-коміт: feat: design tokens and base UI primitives.

Глибока навігація (таби, стеки, deep links) — у модулі навігації. Тут Router згадується лише настільки, щоб зрозуміти роль app/.

Живі прев’ю в статті. Нижче поруч із кодом примітивів стоїть компонент ::react-native-preview: у рамці телефону рендериться react-native-web (не справжній симулятор iOS/Android). Імпорти з @/…, Expo Router і пакети expo-* у прев’ю не працюють — тому демо зібрані самодостатнім TSX (токени «вшиті» в файл). У реальному Nomad ви пишете той самий UI через tokens і @/shared/ui.

Дві моделі розкладки (і що обирає курс)

За шарами (layer-based)

components/
screens/
hooks/
services/

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

За фічами (feature-based)

features/
  trips/
    ui/
    model/
  auth/
    ui/
    model/
shared/
  ui/
  theme/

Код поїздок лежить поруч (екранні шматки, типи, локальна логіка). Спільне (кнопка, тема) — у shared/.

Аналогія. Шари — як розкласти речі за типом: «усі викрутки в одній шухляді». Фічі — як розкласти за кімнатами: «усе для кухні разом, усе для ванної — разом, а універсальний інструмент — у спільній шафі». Для продукту з кількома доменами (поїздки, місця, профіль) зручніша друга схема.

Курс: гібрид, який добре лягає на Expo Router:

  • app/ — лише маршрути (тонка оболонка);
  • src/features/ — код фіч;
  • src/shared/ — тема, UI-примітиви, утиліти.

Цільова структура Nomad

Точні імена файлів у шаблоні Expo можуть трохи відрізнятися. Нижче — ролі папок; повний код усього проєкту (знімок після цієї статті) — у розділі Nomad в кінці матеріалу (::code-tree з реальними файлами).

Репозиторій: https://github.com/arakviel/nomad.

Поки читаєте теорію — достатньо розуміти навіщоapp/, src/shared/, src/features/. Копіювати код зручніше з розділу Nomad: там повний знімок, не «порожні» stubs.
app/
маршрути
У Expo Router файли тут стають екранами застосунку. app/index.tsx — початковий екран. app/_layout.tsx — спільна обгортка (провайдери, загальний стек). Детальні правила імен — у статті про Router; зараз не роздувайте дерево маршрутів.
src/features/
фічі
Кожна велика тема продукту (поїздки, автентифікація, карта) отримає свою підпапку. На цьому кроці достатньо завести trips/ як заготовку — логіку поїздок додасте пізніше.
src/shared/theme/
тема
Єдине місце чисел і кольорів. Екрани не вигадують #2563eb щоразу — беруть tokens.colors.primary.
src/shared/ui/
примітиви
Маленькі будівельні блоки без бізнес-смислу «поїздка»: екран-обгортка, текст, кнопка.
assets/
файли
Іконки, splash, зображення. Не змішувати з TypeScript-логікою.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #ffffff

rectangle "app/\n(маршрути)" as APP #e3f2fd
rectangle "src/features/\n(поїздки, auth, …)" as FEAT #fff3e0
rectangle "src/shared/\n(theme + ui)" as SHARED #e8f5e9

APP --> FEAT : екран збирає\nшматки фічі
APP --> SHARED : Screen, Button,\nтокени
FEAT --> SHARED : фіча теж\nвикористовує UI

note bottom of APP
  Тонкий шар:
  «який екран»
  а не «уся логіка світу»
end note

@enduml

Псевдоніми імпортів (@/…)

Щоб не писати ../../../../shared/ui/Button, у TypeScript налаштовують path alias (псевдонім шляху).

Відкрийте tsconfig.json у Nomad. Зазвичай він уже має "extends": "expo/tsconfig.base". Додайте (або узгодьте) шляхи до src:

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

Тоді імпорт виглядає так:

import { Button } from '@/shared/ui/Button';
import { tokens } from '@/shared/theme';

Зберегти tsconfig.json

Перезапустити TypeScript-сервер у VS Code

Command Palette → TypeScript: Restart TS Server (інколи достатньо закрити/відкрити файл).

Перезапустити Metro за потреби

Якщо збирач «не бачить» аліас: зупинити expo start і знову npx expo start. У сучасних шаблонах Expo аліаси з tsconfig зазвичай підхоплюються; якщо ні — перевірте документацію вашої версії SDK щодо experiments.tsconfigPaths / налаштувань Metro.

Псевдонім @/*./src/* означає, що файли всередині app/ імпортують з @/…, але самі лежать не під src/. Це нормально: маршрути лишаються в app/ за правилами Expo Router.

Design tokens — словник вигляду

Навіщо

Уявіть, що «основний синій» записаний у двадцяти файлах. Дизайнер просить зробити бренд трохи темнішим — доведеться шукати двадцять місць і помилитися в половині.

Design tokens (токени дизайну) — іменовані значення: колір, відступ, розмір шрифту, радіус скруглення. Змінили токен — оновився весь UI, який на нього посилається.

Файл токенів

Створіть src/shared/theme/tokens.ts:

export const tokens = {
  colors: {
    background: '#F8FAFC',
    surface: '#FFFFFF',
    text: '#0F172A',
    textSecondary: '#64748B',
    primary: '#2563EB',
    primaryPressed: '#1D4ED8',
    border: '#E2E8F0',
    danger: '#DC2626',
    onPrimary: '#FFFFFF',
  },
  spacing: {
    xs: 4,
    sm: 8,
    md: 16,
    lg: 24,
    xl: 32,
  },
  radius: {
    sm: 8,
    md: 12,
    lg: 16,
  },
  fontSize: {
    sm: 14,
    md: 16,
    lg: 20,
    xl: 28,
  },
} as const;

export type Tokens = typeof tokens;
colors
колір
Не «синій1 / синій2», а роль: primary (головна дія), text (основний текст), background (фон екрана). Ролі легше міняти під темну тему пізніше.
spacing
відступи
Сходинка 4 / 8 / 16 / 24… замість випадкових 13 і 17. Око сприймає ритм спокійніше.
as const
TypeScript
Зафіксувати літеральні типи значень. Підказки в IDE стають точнішими.

Експорт з src/shared/theme/index.ts:

export { tokens } from './tokens';
export type { Tokens } from './tokens';
Поки не обов’язково підключати темну тему й контекст. Достатньо одного набору токенів. Темну тему курс торкнеться в статті про StyleSheet і темизацію.

Примітив Screen — рамка екрана

Майже кожен екран потребує:

  • безпечних відступів від «чубчика» й домашньої смужки (пізніше поглибимо Safe Area);
  • фонового кольору;
  • горизонтальних полів.

Поки зробимо просту обгортку на View + padding з токенів. Safe Area підключимо свідомо в UI-модулі; зараз головне — звичка не дублювати фон у кожному файлі.

src/shared/ui/Screen.tsx:

import { ReactNode } from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';

import { tokens } from '@/shared/theme';

type ScreenProps = {
  children: ReactNode;
  style?: ViewStyle;
};

export function Screen({ children, style }: ScreenProps) {
  return <View style={[styles.root, style]}>{children}</View>;
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
    backgroundColor: tokens.colors.background,
    paddingHorizontal: tokens.spacing.md,
    paddingVertical: tokens.spacing.lg,
  },
});
children
ReactNode
Уміст екрана — заголовки, кнопки, списки — передається всередину обгортки.
flex: 1
layout
Екран займає доступну висоту. Без цього фон часто «не дотягується» до низу на порожніх екранах.
style?
опційно
Дозволяє рідкісний виняток (інший padding), не ламаючи значення за замовчуванням.

Як це виглядає на екрані

Нижче — ідея Screen: фон, горизонтальні поля, flex: 1. У прев’ю токени записані літералами (у проєкті — tokens.colors.background тощо).

TSXScreenDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Примітив AppText — текст з правилами

У React Native не можна покласти рядок просто в View, як у div у вебі — потрібен компонент Text. Якщо в кожному місці писати свій fontSize і колір, ритм типографіки роз’їдеться.

src/shared/ui/AppText.tsx:

import { ReactNode } from 'react';
import { StyleSheet, Text, TextProps, TextStyle } from 'react-native';

import { tokens } from '@/shared/theme';

type AppTextVariant = 'body' | 'title' | 'subtitle' | 'caption';

type AppTextProps = TextProps & {
  children: ReactNode;
  variant?: AppTextVariant;
  color?: string;
  style?: TextStyle;
};

export function AppText({
  children,
  variant = 'body',
  color = tokens.colors.text,
  style,
  ...rest
}: AppTextProps) {
  return (
    <Text style={[styles.base, styles[variant], { color }, style]} {...rest}>
      {children}
    </Text>
  );
}

const styles = StyleSheet.create({
  base: {
    color: tokens.colors.text,
  },
  body: {
    fontSize: tokens.fontSize.md,
    lineHeight: 24,
  },
  title: {
    fontSize: tokens.fontSize.xl,
    fontWeight: '700',
    lineHeight: 34,
  },
  subtitle: {
    fontSize: tokens.fontSize.lg,
    fontWeight: '600',
    lineHeight: 28,
  },
  caption: {
    fontSize: tokens.fontSize.sm,
    color: tokens.colors.textSecondary,
    lineHeight: 20,
  },
});
Назва AppText, а не Text, щоб не плутати з імпортом з react-native і одразу бачити в коді: «це наш узгоджений текст».

Варіанти типографіки вживу

Чотири ролі поруч: title, subtitle, body, caption. У проєкті це один компонент AppText з пропом variant.

TSXAppTextDemo.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Примітив Button — головна дія

Кнопка — місце, де найчастіше роз’їжджаються відступи й кольори. Зробимо простий варіант на Pressable (сучасніший за старі TouchableOpacity для нових екранів; деталі жестів — пізніше).

src/shared/ui/Button.tsx:

import { Pressable, StyleSheet, Text, ViewStyle } from 'react-native';

import { tokens } from '@/shared/theme';

type ButtonProps = {
  label: string;
  onPress: () => void;
  disabled?: boolean;
  style?: ViewStyle;
};

export function Button({ label, onPress, disabled = false, style }: ButtonProps) {
  return (
    <Pressable
      accessibilityRole="button"
      disabled={disabled}
      onPress={onPress}
      style={({ pressed }) => [
        styles.base,
        pressed && !disabled ? styles.pressed : null,
        disabled ? styles.disabled : null,
        style,
      ]}
    >
      <Text style={styles.label}>{label}</Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  base: {
    backgroundColor: tokens.colors.primary,
    paddingVertical: tokens.spacing.sm + 4,
    paddingHorizontal: tokens.spacing.md,
    borderRadius: tokens.radius.md,
    alignItems: 'center',
  },
  pressed: {
    backgroundColor: tokens.colors.primaryPressed,
  },
  disabled: {
    opacity: 0.5,
  },
  label: {
    color: tokens.colors.onPrimary,
    fontSize: tokens.fontSize.md,
    fontWeight: '600',
  },
});
label
string
Текст кнопки українською, наприклад «Нова поїздка». Поки рядок передається пропсом; винесення всіх рядків у словник i18n — пізніше.
accessibilityRole
a11y
Підказка для скрінрідера: це кнопка. Повний прохід доступності — в окремій статті; звичка ставити роль — корисна з першого примітива.
pressed
стан Pressable
Функція в style отримує, чи палець зараз натиснутий — можна змінити колір без окремого state.

За бажанням src/shared/ui/index.ts:

export { AppText } from './AppText';
export { Button } from './Button';
export { Screen } from './Screen';

Кнопка вживу: primary, pressed, disabled

Натисніть «Нова поїздка» — лічильник і колір покажуть pressed. Друга кнопка навмисно disabled.

TSXButtonDemo.tsx
Android
9:41

Loading…

react-native-web · not a real device


Зібрати домашній екран Nomad

Оновіть app/index.tsx (шлях може бути app/(tabs)/index.tsx у вашому шаблоні — важливий домашній екран). Мета: тонкий маршрут + примітиви + українські рядки.

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

import { tokens } from '@/shared/theme';
import { AppText, Button, Screen } from '@/shared/ui';

export default function HomeScreen() {
  return (
    <Screen>
      <View style={styles.header}>
        <AppText variant="title">Мандрівник</AppText>
        <AppText variant="caption" style={styles.caption}>
          Щоденник подорожей
        </AppText>
      </View>

      <AppText style={styles.body}>
        Тут згодом зʼявиться список поїздок. Зараз закладаємо структуру проєкту та спільні
        компоненти.
      </AppText>

      <Button
        label="Нова поїздка"
        onPress={() => {
          // Навігацію на форму додамо в модулі Router
          console.log('TODO: відкрити створення поїздки');
        }}
      />
    </Screen>
  );
}

const styles = StyleSheet.create({
  header: {
    marginBottom: tokens.spacing.lg,
    gap: tokens.spacing.xs,
  },
  caption: {
    marginTop: tokens.spacing.xs,
  },
  body: {
    marginBottom: tokens.spacing.lg,
  },
});

Зберегти файли theme та ui

Переконатися, що @/ резолвиться

Запустити npx expo start у Nomad

Перевірити екран

Має бути заголовок «Мандрівник», підзаголовок, абзац і синя кнопка. Натискання пише в терминал Metro TODO: ….

Прев’ю домашнього екрана (самодостатній демо-файл)

У Nomad імпорти йдуть з @/shared/ui. Нижче — той самий вигляд, зібраний у одному файлі, щоб побачити результат без запуску Metro.

TSXHomeScreen.tsx
iPhone
9:41

Loading…

react-native-web · not a real device

Якщо TypeScript скаржиться на @/…, перевірте paths у tsconfig.json і що файли справді лежать під src/shared/..., а не під shared/ у корені за звичкою з іншого шаблону.

Кореневий app/_layout.tsx поки можна лишити близьким до шаблону. Головне — не видалити обгортку Router. Якщо в layout є Stack/Tabs з шаблону — не ламайте навігацію; змінюєте насамперед вміст домашнього екрана.


Платформні відмінності: .ios.tsx і .android.tsx

Інколи один і той самий компонент на iPhone і Android має різну реалізацію (рідко на старті, частіше з нативними модулями). React Native уміє підставити файл за суфіксом:

ФайлКоли береться
Button.ios.tsxзбірка під iOS
Button.android.tsxзбірка під Android
Button.tsxзапасний / спільний варіант
Button.native.tsxбудь-яка нативна платформа (не web)

Імпорт лишається:

import { Button } from '@/shared/ui/Button';

Збирач сам обере суфікс.

На цьому кроці курсу окремі .ios / .android файли не обов’язкові. Достатньо знати механізм, щоб не здивуватися в чужому репозиторії. Для Nomad поки один Button.tsx на обидві платформи.

Також існує об’єкт Platform з react-native (Platform.OS === 'ios'). Дрібні відмінності (один відступ) часто роблять через Platform.select, а не через два файли. Порівняння підходів — у статті про базові компоненти.


Що не класти в app/

Великі форми й бізнес-логіка

Краще src/features/trips/ui/TripForm.tsx, а в app/ — кілька рядків імпорту й рендеру.

Токени й кнопки

Тільки src/shared/…. Інакше фіча «поїздки» потягне за собою чужі кольори.

Секрети API

Не в коміти. Пізніше — змінні оточення; зараз секретів ще немає.

Міні-проєкт «від А до Я»: Візитка

Мета

Окремий маленький застосунок projects/business-card (не змішувати з Nomad), щоб закріпити:

  • tokens;
  • Screen / AppText / Button;
  • два екрани-заглушки без глибокої навігації (два файли маршрутів або два простих екрани в Router).

Сценарій продукту

Екран 1 — візитка: ім’я, роль, коротка біо.
Екран 2 — контакти: email / телефон як текст (без реальної інтеграції).
Кнопка «Контакти» на першому екрані веде на другий (через router.push з expo-router — мінімум API; якщо Router лякає, зробіть два проєкти blank… але краще один проєкт з app/index.tsx і app/contacts.tsx).

Кроки

Створити проєкт

cd /path/to  # поруч: git clone https://github.com/arakviel/nomad.git
npx create-expo-app@latest business-card
cd business-card

Додати src/shared/theme і src/shared/ui

Можна скопіювати ідею токенів і примітивів з Nomad (навмисно повторити руками — краще, ніж copy-paste без читання).

Налаштувати @/*./src/* у tsconfig

Зібрати app/index.tsx — візитка

Українською: ім’я, «Розробник інтерфейсів», кнопка «Контакти».

Додати app/contacts.tsx

Текст контактів + кнопка «Назад» (router.back() з expo-router).

Перевірити на пристрої / симуляторі

Обидва екрани відкриваються, стилі з токенів, без «сирих» #ff0000 у JSX.

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

  • Є tokens.ts і щонайменше три примітиви.
  • На екранах немає дубльованих hex-кольорів «від руки» (крім самого файлу токенів).
  • Два маршрути працюють.
  • Проєкт лежить у projects/business-card, Nomad не зламаний.

Орієнтир вигляду (візитка)

У реальному міні-проєкті буде два маршрути (index / contacts) і router.push. Тут — один екран-візитка з кнопкою, яка «перемикає» вміст (імітація двох екранів без Router у прев’ю).

TSXBusinessCard.tsx
iPhone
9:41

Loading…

react-native-web · not a real device


Nomad: структура, токени та UI-примітиви

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

Nomad — не «hello world назавжди». Уже на старті потрібна зрозуміла структура: куди класти тему, спільні кнопки, майбутні фічі. Інакше через три статті все звалиться в один App.tsx.

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

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

  • Репозиторій створено (стаття 04): Expo + TypeScript + app/ маршрути.

Додаємо в цій статті:

  • src/shared/theme — design tokens.
  • src/shared/uiScreen, AppText, Button.
  • Псевдонім @/* у tsconfig.
  • Домашній екран збирається з примітивів, не з «голого» JSX на всю сторінку.

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

Готовий код 1:1 з Nomad. Найшвидший шлях: git pull у клоні репо. Нижче — весь проєкт на момент цієї статті (усі файли з кодом), не фрагмент «лише нові папки». Клік по файлу в дереві → повний вміст для копіпасту.
cd /path/to/nomad
git pull
npm install
npx expo start

Перевірка

  1. npx expo start — екран відкривається без помилок імпорту @/….
  2. На екрані видно текст через AppText і кнопку Button.
  3. Файли лежать у src/shared/… як у дереві нижче.

Коміт

Якщо збирали вручну (не через git pull на вже запушений репо):

cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: design tokens and base UI primitives

Material: content/15.react-native/05.project-structure-and-conventions.md
EOF
)"
git push

У публічному репо цей коміт уже є — після git pull повторно комітити не потрібно, якщо ви не змінювали код локально.


Підсумок

Далі — базові компоненти React Native (View, Text, Image, Pressable, ScrollView) уже з глибшим розбором API, на фундаменті структури з цієї статті.


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

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

  1. У Nomad створити tokens + три примітиви + український домашній екран.
  2. Пояснити своїми словами різницю між app/ і src/shared/ui/.
  3. Замінити один «магічний» відступ у своєму коді на tokens.spacing.*.

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

  1. Виконати міні-проєкт Візитка.
  2. Додати в tokens ще одну роль кольору (наприклад success) і використати її в AppText або другорядній кнопці.
  3. Зробити src/shared/ui/index.ts і імпортувати примітиви одним рядком.

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

  1. Коротко (пів сторінки) порівняти feature-based і layer-based для команди з 5 людей.
  2. Додати другий варіант кнопки variant: 'primary' | 'secondary' без дублювання всього компонента.
  3. Налаштувати .vscode/settings.json у Nomad з typescript.tsdk на проєктний TypeScript (з попередньої статті) і перевірити, що @/ підсвічується без помилок.

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

Copyright © 2026