Введение: почему файловая маршрутизация — будущее навигации в React Native
Навигация — это сердце любого мобильного приложения. Без неё пользователь застрянет на одном экране, а вы застрянете без пользователей. Годами мы в экосистеме React Native конфигурировали навигаторы вручную через React Navigation: создавали стеки, вкладки, вложенные навигаторы, прописывали каждый экран руками. Работало? Ну да. Было ли это удобно? Если честно, не особо — особенно когда проект разрастался до десятков экранов и ты начинал путаться в конфигах.
В 2026 году ситуация изменилась кардинально. Expo Router принёс в React Native концепцию файловой маршрутизации, которую веб-разработчики уже давно знают по Next.js и Remix. Суть проста до безобразия: структура файлов в директории app/ определяет навигационную структуру приложения. Создали файл — получили маршрут. Создали папку — получили вложенный путь. Никакой ручной конфигурации.
Но Expo Router — это не просто «файлы вместо конфига». Это полноценный навигационный фреймворк, построенный поверх React Navigation, который автоматически генерирует навигационное дерево, обеспечивает типобезопасность маршрутов, автоматический deep linking и даже серверный рендеринг для веба. А с выходом Expo SDK 55 и React Native 0.83.1 всё это стало ещё стабильнее.
В этом руководстве мы разберём Expo Router от и до — от базовой настройки до продвинутых сценариев вроде аутентификации, динамических маршрутов и нового SplitView API для планшетов. Каждый раздел с рабочими примерами кода, которые можно использовать прямо сегодня.
Настройка Expo Router с Expo SDK 55
Expo SDK 55, вышедший в начале 2026 года, — серьёзный релиз. Под капотом React Native 0.83.1 и React 19.2, а поддержка старой архитектуры полностью удалена. Всё работает на New Architecture: Fabric, TurboModules, Bridgeless Mode. Плюс появился Hermes V1 — новая мажорная версия JavaScript-движка.
Итак, давайте создадим новый проект. Это буквально одна команда:
# Создание нового проекта с шаблоном навигации
npx create-expo-app@latest my-app --template tabs
# Переход в директорию проекта
cd my-app
# Запуск dev-сервера
npx expo start
Шаблон tabs создаст проект с уже настроенным Expo Router и табами. Но если вы хотите добавить Expo Router в существующий проект, установите нужные пакеты:
# Установка Expo Router и зависимостей
npx expo install expo-router expo-linking expo-constants expo-status-bar
Далее обновите app.json — здесь указывается схема для deep linking, включаются типизированные маршруты и настраивается бандлер:
{
"expo": {
"name": "my-app",
"slug": "my-app",
"scheme": "myapp",
"web": {
"bundler": "metro",
"output": "server"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}
И укажите точку входа в package.json:
{
"main": "expo-router/entry"
}
После этого Expo Router начнёт автоматически сканировать директорию app/ и генерировать навигацию на основе файловой структуры. Всё работает из коробки — никаких дополнительных плясок.
Важные нюансы SDK 55
Раз уж мы заговорили про SDK 55, стоит отметить несколько вещей, которые точно повлияют на вашу работу:
- Legacy Architecture удалена полностью — если у вас были старые нативные модули на Bridge, их придётся мигрировать на TurboModules. Обратного пути нет.
- React 19.2 — доступны все новые фишки React, включая Server Components (для веба) и улучшенный Suspense.
- Hermes V1 — новая версия движка, совместимая с ECMAScript 2024, быстрее запуск и меньше потребление памяти.
- Синхронное обновление экранов — по умолчанию экраны обновляются синхронно, что устраняет то раздражающее мерцание при навигации.
Ключевые концепции файловой маршрутизации
Ядро Expo Router — директория app/ в корне проекта. Каждый файл, экспортирующий React-компонент по умолчанию, становится маршрутом. Имя файла равно URL-путь.
Просто и элегантно.
Базовая структура директории
app/
├── _layout.tsx # Корневой layout (обязательный)
├── index.tsx # Главный экран (путь: /)
├── about.tsx # О приложении (путь: /about)
├── settings.tsx # Настройки (путь: /settings)
└── profile/
├── _layout.tsx # Layout секции профиля
├── index.tsx # Профиль (путь: /profile)
└── edit.tsx # Редактирование (путь: /profile/edit)
Видите закономерность? Файл index.tsx в любой папке — это корневой маршрут этой папки. Подпапки создают вложенные пути. А файлы _layout.tsx определяют, как отображаются дочерние экраны — это, по сути, аналоги навигаторов из React Navigation.
Файлы _layout.tsx: навигаторы «по-новому»
Layout-файлы — пожалуй, самая важная концепция в Expo Router. Они определяют навигационную оболочку для всех экранов в своей директории. Корневой app/_layout.tsx обязателен — это точка входа в навигацию всего приложения.
// app/_layout.tsx — корневой layout приложения
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
);
}
Кстати, вы можете не перечислять Stack.Screen явно — Expo Router автоматически подхватит все файлы в директории. Но явное объявление полезно, когда нужно задать параметры конкретных экранов (заголовок, анимации и т.д.).
Группы маршрутов с (скобками)
Иногда нужно объединить экраны в группу, но при этом не хочется, чтобы это влияло на URL. Для этого есть директории в круглых скобках — они не попадают в путь маршрута:
app/
├── _layout.tsx
├── (auth)/
│ ├── _layout.tsx
│ ├── login.tsx # путь: /login (НЕ /auth/login!)
│ └── register.tsx # путь: /register
├── (tabs)/
│ ├── _layout.tsx
│ ├── index.tsx # путь: /
│ ├── explore.tsx # путь: /explore
│ └── profile.tsx # путь: /profile
└── modal.tsx # путь: /modal
Группы идеально подходят для организации кода без создания лишних уровней вложенности в URL. Например, экраны аутентификации живут в папке (auth), но их URL не содержат «auth» — пользователь видит просто /login и /register. Удобно, правда?
Скрытие файлов от маршрутизатора
Не всякий файл в директории app/ должен становиться маршрутом. Компоненты, утилиты, хуки — всё это можно спокойно размещать рядом с маршрутами, если правильно их именовать:
- Файлы и папки с
_(подчёркивание) в начале имени игнорируются маршрутизатором (кроме_layout.tsx) - Тестовые файлы (
*.test.tsx) тоже игнорируются - Файлы без default export не создают маршруты
app/
├── _layout.tsx # Layout — обрабатывается особым образом
├── _utils/ # Утилиты — игнорируется маршрутизатором
│ └── helpers.ts
├── _components/ # Компоненты — игнорируется
│ └── Header.tsx
├── index.tsx # Маршрут: /
└── about.tsx # Маршрут: /about
Навигационные layout-ы: Stack, Tabs и Drawer
Expo Router предоставляет три основных типа навигационных layout-ов. Давайте разберём каждый.
Stack — стековая навигация
Stack — самый распространённый тип навигации. Экраны «складываются» один поверх другого, а кнопка «Назад» возвращает на предыдущий. Классика мобильного UX.
// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
);
}
Tabs — навигация вкладками
Табы — классический паттерн мобильного UI. В 2026 году Expo Router предлагает два варианта: стандартные Tabs и новые Native Tabs.
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabsLayout() {
return (
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
);
}
Native Tabs — нативные вкладки
А вот это — новинка, которая лично меня впечатлила. Native Tabs используют нативный UITabBarController на iOS и BottomNavigationView на Android вместо JavaScript-реализации. Результат — мгновенный отклик, нативные анимации и автоматическая обработка safe area insets. Больше никаких танцев с SafeAreaView для таб-бара!
// app/(tabs)/_layout.tsx
import { NativeTabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function NativeTabsLayout() {
return (
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
);
}
Ключевое преимущество Native Tabs — safe area insets обрабатываются автоматически на платформенном уровне. Не нужно вручную добавлять отступы или заворачивать контент в SafeAreaView. Платформа сама разберётся.
Drawer — боковое меню
Drawer отлично подходит для приложений с большим количеством разделов. Для использования нужно поставить дополнительный пакет:
npx expo install @react-navigation/drawer react-native-gesture-handler react-native-reanimated
// app/_layout.tsx
import { Drawer } from 'expo-router/drawer';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function DrawerLayout() {
return (
);
}
Динамические маршруты: [param] и [...rest]
Статические маршруты — это замечательно, но в реальных приложениях почти всегда нужны динамические параметры. Expo Router поддерживает их через специальное именование файлов в квадратных скобках.
Параметры маршрута [param]
Файл с именем в квадратных скобках создаёт динамический сегмент пути. Например, [id].tsx будет матчить любое значение на этой позиции в URL:
app/
└── products/
├── _layout.tsx
├── index.tsx # /products
└── [id].tsx # /products/123, /products/abc, /products/что-угодно
Получить значение параметра внутри компонента можно через хук useLocalSearchParams:
// app/products/[id].tsx
import { View, Text, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
export default function ProductScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
Товар: {id}
Здесь загружаются данные товара по ID
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20, backgroundColor: '#0f0f23' },
title: { fontSize: 24, fontWeight: 'bold', color: '#fff' },
subtitle: { fontSize: 16, color: '#aaa', marginTop: 8 },
});
Вложенные динамические параметры
Можно создавать несколько уровней динамических параметров — и это работает именно так, как вы ожидаете:
app/
└── users/
└── [userId]/
├── _layout.tsx
├── index.tsx # /users/42
└── posts/
└── [postId].tsx # /users/42/posts/7
// app/users/[userId]/posts/[postId].tsx
import { useLocalSearchParams } from 'expo-router';
export default function UserPostScreen() {
const { userId, postId } = useLocalSearchParams<{
userId: string;
postId: string;
}>();
return (
Пользователь: {userId}
Запись: {postId}
);
}
Catch-all маршруты [...rest]
Если нужно перехватить произвольное количество сегментов пути — используйте catch-all маршрут с [...rest]. Это полезно для кастомных 404-страниц или обработки произвольных URL:
app/
├── _layout.tsx
├── index.tsx
└── [...missing].tsx # Перехватывает все неопределённые пути
// app/[...missing].tsx
import { View, Text, StyleSheet } from 'react-native';
import { Link, useLocalSearchParams } from 'expo-router';
export default function NotFoundScreen() {
const { missing } = useLocalSearchParams<{ missing: string[] }>();
return (
404
Страница не найдена
Запрошенный путь: /{missing?.join('/')}
Вернуться на главную
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#0f0f23' },
emoji: { fontSize: 64, fontWeight: 'bold', color: '#6C63FF' },
title: { fontSize: 24, fontWeight: 'bold', color: '#fff', marginTop: 16 },
path: { fontSize: 14, color: '#999', marginTop: 8 },
link: { fontSize: 16, color: '#6C63FF', marginTop: 24, textDecorationLine: 'underline' },
});
Обратите внимание: параметр catch-all маршрута — это массив строк, а не одна строка. Путь /foo/bar/baz передаст missing как ['foo', 'bar', 'baz'].
Типизированные маршруты для TypeScript
Одна из самых полезных (я бы даже сказал — одна из самых недооценённых) возможностей Expo Router — автоматическая генерация типов маршрутов. Когда в app.json включена опция typedRoutes: true, Expo CLI генерирует типы при каждом изменении файловой структуры. Это даёт автодополнение в IDE и проверку маршрутов на этапе компиляции.
// Типы генерируются автоматически в .expo/types/router.d.ts
// Теперь IDE подсказывает допустимые маршруты
import { Link } from 'expo-router';
// TypeScript знает все допустимые пути
О нас // OK
Профиль // OK
Нет // ОШИБКА компиляции!
Для типизации параметров динамических маршрутов используйте дженерик useLocalSearchParams:
import { useLocalSearchParams } from 'expo-router';
// Строгая типизация параметров
const { id } = useLocalSearchParams<{ id: string }>();
// Для необязательных параметров
const { id, tab } = useLocalSearchParams<{
id: string;
tab?: string;
}>();
Типизированные маршруты работают и с хуком useRouter:
import { useRouter } from 'expo-router';
const router = useRouter();
// Автодополнение подскажет допустимые пути
router.push('/products/123'); // OK
router.push('/nonexistent'); // ОШИБКА компиляции!
// Типизация с динамическими параметрами
router.push({
pathname: '/products/[id]',
params: { id: '42' },
});
Скажу честно: после того как привыкаешь к типизированным маршрутам, работать без них уже не хочется. Опечатки в путях навигации — один из самых распространённых и трудноуловимых багов в мобильных приложениях, а типизация полностью устраняет их ещё на этапе разработки.
Защищённые маршруты и аутентификация
Реализация аутентификации — одна из тех задач, которые встречаются буквально в каждом втором приложении. Expo Router предлагает элегантный декларативный подход через Stack.Protected. Это современный способ разделения публичных и приватных экранов, который заменяет старые императивные проверки в каждом компоненте.
Важный момент: защищённые маршруты в Expo Router работают только на клиенте. Они не обеспечивают серверную безопасность — это исключительно UI-уровень, определяющий, какие экраны видит пользователь.
Stack.Protected — декларативный подход
Идея красиво простая: вместо проверки if (!user) redirect('/login') в каждом компоненте, вы объявляете, какие экраны защищены, прямо в layout-файле. А Expo Router сам решает, что показывать.
// app/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '../hooks/useAuth';
export default function RootLayout() {
const { user, isLoading } = useAuth();
if (isLoading) {
return ;
}
return (
{/* Публичные экраны — всегда доступны */}
{/* Экраны аутентификации — только для неавторизованных */}
{/* Защищённые экраны — только для авторизованных */}
);
}
Создание контекста аутентификации
Для работы защищённых маршрутов нужен контекст, который предоставляет информацию о текущем пользователе. Вот полный пример (кстати, этот паттерн можно переиспользовать практически в любом приложении):
// context/AuthContext.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise;
signOut: () => Promise;
signUp: (email: string, password: string, name: string) => Promise;
}
const AuthContext = createContext(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Проверяем сохранённый токен при запуске приложения
loadStoredAuth();
}, []);
async function loadStoredAuth() {
try {
const token = await SecureStore.getItemAsync('authToken');
if (token) {
const response = await fetch('https://api.example.com/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
}
} catch (error) {
console.error('Ошибка загрузки авторизации:', error);
} finally {
setIsLoading(false);
}
}
async function signIn(email: string, password: string) {
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Неверные учётные данные');
}
const { token, user: userData } = await response.json();
await SecureStore.setItemAsync('authToken', token);
setUser(userData);
}
async function signOut() {
await SecureStore.deleteItemAsync('authToken');
setUser(null);
}
async function signUp(email: string, password: string, name: string) {
const response = await fetch('https://api.example.com/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!response.ok) {
throw new Error('Ошибка регистрации');
}
const { token, user: userData } = await response.json();
await SecureStore.setItemAsync('authToken', token);
setUser(userData);
}
return (
{children}
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth должен использоваться внутри AuthProvider');
}
return context;
}
Провайдер оборачивает layout:
// app/_layout.tsx
import { Slot } from 'expo-router';
import { AuthProvider } from '../context/AuthContext';
export default function RootLayout() {
return (
);
}
// Навигатор в отдельном компоненте, чтобы иметь доступ к AuthContext
function RootNavigator() {
const { user, isLoading } = useAuth();
if (isLoading) {
return ;
}
return (
);
}
Deep linking: всё работает автоматически
Вот что мне по-настоящему нравится в файловой маршрутизации — deep linking из коробки. В React Navigation настройка deep linking требовала отдельного linking config с ручным маппингом URL на экраны. С Expo Router каждый файл автоматически получает свой URL, и deep linking просто работает.
Как это работает
Когда пользователь открывает ссылку myapp://products/42, Expo Router автоматически:
- Разбирает URL на сегменты:
productsи42 - Находит файл
app/products/[id].tsx - Передаёт
{ id: '42' }как параметры маршрута - Навигирует на нужный экран с правильным стеком навигации
Для настройки достаточно указать схему в app.json:
{
"expo": {
"scheme": "myapp"
}
}
И всё! Приложение теперь реагирует на ссылки вида myapp://любой/путь. Для веба URL будут обычными HTTP-путями.
Универсальные ссылки (Universal Links / App Links)
Для production-приложений обычно нужны не кастомные схемы, а универсальные ссылки — те, которые открывают приложение по обычному HTTPS-URL. Expo Router поддерживает это через ассоциированные домены:
{
"expo": {
"scheme": "myapp",
"ios": {
"associatedDomains": ["applinks:myapp.example.com"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "myapp.example.com",
"pathPrefix": "/"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Теперь ссылка https://myapp.example.com/products/42 откроет приложение прямо на экране товара. А если приложение не установлено — покажет веб-версию (если она настроена, конечно).
Native Tabs и SplitView API для планшетов
С ростом популярности планшетов и десктопных приложений через Mac Catalyst адаптивный дизайн навигации становится всё важнее. Expo Router предлагает пару инструментов для этого.
Native Tabs: нативная производительность
Мы уже касались Native Tabs выше, но давайте копнём чуть глубже. Главное отличие от обычных Tabs — рендеринг полностью на нативном уровне. Что это даёт:
- Автоматическая обработка safe area insets — таб-бар правильно позиционируется на устройствах с вырезами, Dynamic Island и закруглёнными углами. Никакого дополнительного кода.
- Нативные анимации переключения — переходы между вкладками используют платформенные анимации, а не JavaScript.
- Синхронное обновление — экраны обновляются синхронно, что убирает мерцание белого фона при переключении вкладок.
- Интеграция с системой — на iPad нативный таб-бар адаптируется под размер экрана, на macOS интегрируется с тулбаром.
SplitView: адаптивная навигация для больших экранов
SplitView — экспериментальный API, который создаёт двухпанельную навигацию в стиле iPad. Слева — список (например, чаты), справа — детальный контент (конкретный чат). На iPhone это автоматически превращается в стандартный стек.
Имейте в виду: на момент написания SplitView является экспериментальным и работает только на iOS. На остальных платформах он фолбечится на Slot navigator.
// app/(split)/_layout.tsx
import { SplitView } from 'expo-router';
export default function SplitLayout() {
return (
{/* Левая панель — список */}
{/* Правая панель — детали */}
);
}
Файловая структура для SplitView:
app/
└── (split)/
├── _layout.tsx # SplitView layout
├── index.tsx # Sidebar (список)
└── [id].tsx # Detail (содержимое)
На iPad пользователь увидит обе панели одновременно: слева список, справа — контент выбранного элемента. На iPhone — обычный стековый переход. И всё это — без единой строчки платформозависимого кода.
Навигация между экранами
Expo Router даёт несколько способов навигации. Разберём каждый.
Компонент Link — декларативная навигация
Link — самый простой способ создать навигацию. Компонент рендерит нажимаемый элемент и выполняет переход при тапе:
import { Link } from 'expo-router';
import { View, Text } from 'react-native';
export default function HomeScreen() {
return (
{/* Простая текстовая ссылка */}
О приложении
{/* Ссылка с динамическим параметром */}
Товар #42
{/* Ссылка с объектом href — для сложных параметров */}
Отзывы о товаре
{/* Ссылка с заменой текущего экрана */}
Выйти и авторизоваться
{/* Ссылка внутри кастомного компонента */}
Настройки
);
}
Проп asChild особенно удобен — он позволяет обернуть любой компонент в навигационную ссылку, сохранив его внешний вид и поведение.
Хук useRouter — императивная навигация
Когда нужно перейти на другой экран программно (после отправки формы, по результату API-вызова), используйте хук useRouter:
import { useRouter } from 'expo-router';
import { View, Button, Alert } from 'react-native';
export default function LoginScreen() {
const router = useRouter();
async function handleLogin() {
try {
await signIn(email, password);
// router.push — добавляет экран в стек (можно вернуться назад)
router.push('/dashboard');
// router.replace — заменяет текущий экран (нельзя вернуться)
router.replace('/dashboard');
// router.navigate — умная навигация, избегает дублей в стеке
router.navigate('/dashboard');
} catch (error) {
Alert.alert('Ошибка', 'Неверные учётные данные');
}
}
async function handleBack() {
// router.back() — возврат на предыдущий экран
router.back();
// router.canGoBack() — проверка
if (router.canGoBack()) {
router.back();
}
// router.dismiss() — закрыть модальное окно
router.dismiss();
// router.dismissAll() — закрыть все модальные окна
router.dismissAll();
}
return (
);
}
Разница между push, navigate и replace
Это частый источник путаницы, так что давайте разложим по полочкам:
- router.push('/screen') — всегда добавляет новый экран в стек. Вызвали
push('/screen')три раза — в стеке три копии. Используйте, когда точно хотите добавить новый экран. - router.navigate('/screen') — умная навигация. Если экран уже есть в стеке — вернётся к нему. Если нет — добавит новый. Это предпочтительный метод в большинстве случаев.
- router.replace('/screen') — заменяет текущий экран новым. Предыдущий удаляется из стека. Идеально для сценария после авторизации — чтобы пользователь не мог вернуться на экран логина.
Навигация с параметрами
import { useRouter } from 'expo-router';
const router = useRouter();
// Простой путь со встроенным параметром
router.push('/products/42');
// Объект с pathname и params — для сложных случаев
router.push({
pathname: '/products/[id]',
params: {
id: '42',
source: 'home',
showReviews: 'true',
},
});
// Параметры доступны через useLocalSearchParams
// { id: '42', source: 'home', showReviews: 'true' }
Практический пример: приложение с аутентификацией
Теория — это хорошо, но давайте уже соберём всё вместе. Построим приложение с экранами авторизации, лентой постов и профилем пользователя.
Файловая структура проекта
app/
├── _layout.tsx # Корневой layout с AuthProvider
├── (auth)/
│ ├── _layout.tsx # Stack для экранов авторизации
│ ├── login.tsx # Экран входа
│ └── register.tsx # Экран регистрации
├── (tabs)/
│ ├── _layout.tsx # Tabs/NativeTabs layout
│ ├── index.tsx # Лента (главная)
│ ├── search.tsx # Поиск
│ └── profile.tsx # Профиль
├── post/
│ └── [id].tsx # Детали поста (динамический маршрут)
└── settings.tsx # Настройки (модальный экран)
context/
└── AuthContext.tsx # Контекст авторизации
hooks/
└── useAuth.ts # Хук для доступа к авторизации
Корневой layout с аутентификацией
// app/_layout.tsx
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { AuthProvider, useAuth } from '../context/AuthContext';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
export default function RootLayout() {
return (
);
}
function RootNavigator() {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
);
}
return (
{/* Экраны авторизации — только для неавторизованных */}
{/* Основные экраны — только для авторизованных */}
);
}
const styles = StyleSheet.create({
loading: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#0f0f23',
},
});
Layout экранов авторизации
// app/(auth)/_layout.tsx
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
);
}
Экран входа
// app/(auth)/login.tsx
import { useState } from 'react';
import {
View, Text, TextInput, Pressable,
StyleSheet, Alert, KeyboardAvoidingView, Platform,
} from 'react-native';
import { Link, useRouter } from 'expo-router';
import { useAuth } from '../../context/AuthContext';
export default function LoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { signIn } = useAuth();
const router = useRouter();
async function handleSignIn() {
if (!email || !password) {
Alert.alert('Ошибка', 'Заполните все поля');
return;
}
setIsSubmitting(true);
try {
await signIn(email, password);
// После успешного входа Stack.Protected автоматически
// переключит навигацию — ничего не нужно делать вручную!
} catch (error) {
Alert.alert('Ошибка входа', 'Неверный email или пароль');
} finally {
setIsSubmitting(false);
}
}
return (
Добро пожаловать
Войдите в свой аккаунт
{isSubmitting ? 'Входим...' : 'Войти'}
Нет аккаунта? Зарегистрируйтесь
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0f0f23' },
content: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 32, fontWeight: 'bold', color: '#fff', textAlign: 'center' },
subtitle: { fontSize: 16, color: '#aaa', textAlign: 'center', marginTop: 8, marginBottom: 32 },
input: {
backgroundColor: '#1a1a2e',
borderRadius: 12,
padding: 16,
fontSize: 16,
color: '#fff',
marginBottom: 16,
borderWidth: 1,
borderColor: '#333',
},
button: {
backgroundColor: '#6C63FF',
borderRadius: 12,
padding: 16,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontSize: 18, fontWeight: '600' },
link: { color: '#6C63FF', textAlign: 'center', marginTop: 24, fontSize: 15 },
});
Layout вкладок с Native Tabs
// app/(tabs)/_layout.tsx
import { NativeTabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabsLayout() {
return (
(
),
}}
/>
(
),
}}
/>
(
),
}}
/>
);
}
Главный экран — лента с постами
// app/(tabs)/index.tsx
import { View, Text, FlatList, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
interface Post {
id: string;
title: string;
preview: string;
author: string;
date: string;
}
const MOCK_POSTS: Post[] = [
{
id: '1',
title: 'Expo SDK 55: что нового?',
preview: 'Обзор ключевых изменений в новой версии...',
author: 'Алексей',
date: '2026-02-05',
},
{
id: '2',
title: 'React Native 0.83 и New Architecture',
preview: 'Разбираемся с Fabric и TurboModules...',
author: 'Мария',
date: '2026-02-03',
},
{
id: '3',
title: 'Hermes V1: новый движок JavaScript',
preview: 'Что изменилось в первой мажорной версии...',
author: 'Дмитрий',
date: '2026-01-28',
},
];
export default function FeedScreen() {
const router = useRouter();
function renderPost({ item }: { item: Post }) {
return (
router.push(`/post/${item.id}`)}
>
{item.title}
{item.preview}
{item.author}
{item.date}
);
}
return (
item.id}
contentContainerStyle={styles.list}
style={styles.container}
/>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0f0f23' },
list: { padding: 16 },
card: {
backgroundColor: '#1a1a2e',
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: '#2a2a3e',
},
cardTitle: { fontSize: 18, fontWeight: '600', color: '#fff' },
cardPreview: { fontSize: 14, color: '#aaa', marginTop: 6 },
cardMeta: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12 },
cardAuthor: { fontSize: 13, color: '#6C63FF' },
cardDate: { fontSize: 13, color: '#666' },
});
Экран поста с динамическим параметром
// app/post/[id].tsx
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
export default function PostScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// В реальном приложении тут был бы запрос к API
// const { data: post } = useQuery(['post', id], () => fetchPost(id));
return (
Expo SDK 55: что нового?
Алексей
5 февраля 2026
Expo SDK 55 — это большое обновление, которое приносит React Native 0.83.1,
React 19.2, новый Hermes V1 и полный отказ от Legacy Architecture.
В этой статье разберём все ключевые изменения подробно...
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0f0f23', padding: 20 },
title: { fontSize: 26, fontWeight: 'bold', color: '#fff', lineHeight: 34 },
meta: { flexDirection: 'row', gap: 16, marginTop: 12, marginBottom: 20 },
author: { fontSize: 14, color: '#6C63FF' },
date: { fontSize: 14, color: '#666' },
body: { fontSize: 16, color: '#ccc', lineHeight: 26 },
});
Экран профиля с кнопкой выхода
// app/(tabs)/profile.tsx
import { View, Text, Pressable, StyleSheet, Image } from 'react-native';
import { Link } from 'expo-router';
import { useAuth } from '../../context/AuthContext';
export default function ProfileScreen() {
const { user, signOut } = useAuth();
return (
{user?.name?.charAt(0)?.toUpperCase() || '?'}
{user?.name}
{user?.email}
Настройки
Выйти из аккаунта
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0f0f23', alignItems: 'center', paddingTop: 40 },
avatar: {
width: 80, height: 80, borderRadius: 40,
backgroundColor: '#6C63FF', justifyContent: 'center', alignItems: 'center',
},
avatarText: { fontSize: 32, fontWeight: 'bold', color: '#fff' },
name: { fontSize: 22, fontWeight: '600', color: '#fff', marginTop: 16 },
email: { fontSize: 15, color: '#999', marginTop: 4 },
menuItem: {
width: '90%', padding: 16, backgroundColor: '#1a1a2e',
borderRadius: 12, marginTop: 24,
},
menuText: { fontSize: 16, color: '#fff' },
logoutButton: {
width: '90%', padding: 16, backgroundColor: '#2a1a1a',
borderRadius: 12, marginTop: 12, alignItems: 'center',
borderWidth: 1, borderColor: '#ff4444',
},
logoutText: { fontSize: 16, color: '#ff4444', fontWeight: '500' },
});
Обратите внимание на ключевой момент: после signOut() не нужно никуда навигировать вручную. Stack.Protected в корневом layout сам обнаружит, что user стал null, и покажет экраны авторизации. То же самое после signIn() — навигация автоматически переключится на табы. Вот это и есть настоящий декларативный подход в действии.
Советы по производительности и лучшие практики
Expo Router из коробки оптимизирован неплохо, но есть ряд практик, которые помогут выжать из него максимум. Я собрал те, которые реально влияют на результат.
1. Ленивая загрузка тяжёлых экранов
По умолчанию Expo Router загружает все экраны при старте. Для тяжёлых экранов с большими зависимостями стоит использовать React.lazy:
// app/heavy-screen.tsx
import React, { Suspense } from 'react';
import { ActivityIndicator, View } from 'react-native';
const HeavyContent = React.lazy(() => import('../components/HeavyContent'));
export default function HeavyScreen() {
return (
}
>
);
}
2. Используйте router.navigate вместо router.push
Как мы уже обсуждали, router.navigate предотвращает накопление дубликатов в стеке. Это не только чище для UX, но и экономит память:
// Плохо — может создать дубликаты
router.push('/profile');
router.push('/profile');
router.push('/profile'); // В стеке 3 копии!
// Хорошо — умная навигация
router.navigate('/profile');
router.navigate('/profile');
router.navigate('/profile'); // В стеке по-прежнему 1 экран
3. Выносите логику из директории app/
Бизнес-логика и UI-компоненты должны жить вне app/. Файлы маршрутов — это по сути тонкие обёртки, точки входа и не более:
// app/products/[id].tsx — тонкая обёртка
import ProductDetailScreen from '../../screens/ProductDetailScreen';
export default ProductDetailScreen;
// screens/ProductDetailScreen.tsx — вся логика здесь
import { View, Text } from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { useProduct } from '../hooks/useProduct';
export default function ProductDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { data: product, isLoading } = useProduct(id);
// ... вся логика экрана
}
Такой подход делает экраны проще для тестирования, переиспользования и рефакторинга.
4. Загружайте данные по фокусу, а не при монтировании
Экраны на неактивных вкладках всё равно остаются смонтированными. Используйте useFocusEffect, чтобы загружать данные только когда пользователь реально смотрит на экран:
import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
export default function SearchScreen() {
const [results, setResults] = useState([]);
useFocusEffect(
useCallback(() => {
// Вызывается при каждом фокусе экрана
loadTrendingSearches();
return () => {
// Очистка при потере фокуса (опционально)
};
}, [])
);
// ...
}
5. Предзагрузка данных перед навигацией
Если используете TanStack Query, можно начать загрузку данных ещё до перехода на экран. Пользователь даже не заметит задержки:
import { useRouter } from 'expo-router';
import { useQueryClient } from '@tanstack/react-query';
export default function ProductListScreen() {
const router = useRouter();
const queryClient = useQueryClient();
function handleProductPress(productId: string) {
// Начинаем загрузку ДО перехода
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
});
// Переходим — данные уже летят в фоне
router.push(`/products/${productId}`);
}
// ...
}
6. Правильная обработка модальных окон
Для закрытия модальных окон используйте router.dismiss(), а не router.back(). Это гарантирует корректное поведение на всех платформах:
// Открытие
router.push('/settings'); // settings настроен как modal в layout
// Закрытие
router.dismiss(); // Закрывает текущий модал
router.dismissAll(); // Закрывает все модалы
7. Синхронное обновление экранов
В SDK 55 экраны обновляются синхронно по умолчанию — контент появляется мгновенно, без мерцания белого фона. Если вдруг видите мерцание — проверьте, что не переопределили это поведение в конфигурации навигатора.
8. Типизируйте маршруты
Включите typedRoutes: true и используйте TypeScript. Серьёзно. Типизированные маршруты ловят ошибки навигации на этапе компиляции, а не когда тестировщик случайно нажмёт на нужную кнопку.
// tsconfig.json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}
9. Группируйте маршруты осмысленно
Используйте группы (скобки) для логического разделения фич приложения:
app/
├── (auth)/ # Авторизация
├── (tabs)/ # Основная навигация с табами
├── (admin)/ # Панель администрирования
├── (onboarding)/ # Онбординг нового пользователя
└── (modals)/ # Модальные экраны
10. Не злоупотребляйте вложенностью
Глубоко вложенные навигаторы усложняют и навигацию, и deep linking. Старайтесь держать максимум 2-3 уровня. Если структура становится слишком глубокой — это сигнал пересмотреть архитектуру.
Заключение
Expo Router в 2026 году — зрелый, мощный инструмент, который реально упрощает навигацию в React Native. Файловая маршрутизация, типобезопасность, автоматический deep linking, декларативная аутентификация через Stack.Protected, нативные вкладки и адаптивный SplitView — всё это работает из коробки и работает стабильно.
Вот что стоит запомнить:
- Expo Router построен поверх React Navigation — ваши знания React Navigation по-прежнему в деле
- Файловая структура = навигационная структура. Файл в
app/= маршрут - Layout-файлы (
_layout.tsx) определяют навигаторы: Stack, Tabs, Drawer, NativeTabs, SplitView - Типизированные маршруты (
typedRoutes: true) — включайте всегда, это устраняет целый класс багов Stack.Protected— декларативный способ аутентификации (но только клиентский)- Native Tabs обрабатывают safe area автоматически
- SplitView — экспериментальный, только iOS
- Предпочитайте
router.navigateвместоrouter.push - Синхронное обновление экранов устраняет мерцание при переходах
Если начинаете новый проект на React Native — используйте Expo Router. Если есть существующий проект на React Navigation — подумайте о миграции. Файловая маршрутизация — это не просто тренд, а действительно более удобный подход к навигации, который уже стал стандартом в экосистеме Expo.