В Expo Router каждый файл в src/app — это экран со своим адресом: notes/[id].tsx обслуживает /notes/1, /notes/2 и так далее, а _layout.tsx решает, как экраны папки связаны — стеком (Stack) или вкладками (Tabs). Переходят через <Link href> или объект router (push, navigate, replace, back), параметр читают хуком useLocalSearchParams. Поскольку у каждого экрана есть адрес, глубокие ссылки вида notesapp://notes/1 работают без отдельной настройки.
После материала вы сможете
- описать маршруты файлами в
src/app:index, динамический[id], группу(tabs)и_layout - настроить корневой
Stackс вкладкамиTabsи модальным экраном и перейти между ними черезLinkиrouter - передать параметр в динамический маршрут, прочитать его через
useLocalSearchParamsи безопасно вернуться назад после глубокой ссылки - проверить навигацию без телефона: типизированные маршруты в
tscи тест наrenderRouter
Что знать заранее
Проверено на
Expo SDK 57 (expo 57.0.27)Expo Router 57.0.25React Native 0.86.3React 19.2.3TypeScript 6.0
Сверено с документацией: 11 октября 2026 г.
Содержание
Маршруты — это файлы в src/app
Expo Router — файловый маршрутизатор для React Native и веба. Документация формулирует правило просто: все маршруты приложения задаются файлами и папками внутри src/app, у каждого экрана есть URL-путь, совпадающий с расположением файла, а первым открывается index.tsx. Код, который не является экраном, — компоненты, хуки, данные — держат вне src/app, иначе Expo Router попробует сделать из него маршрут.
Практика урока — приложение «Записи»: две вкладки (список и «О приложении»), экран записи с параметром в адресе и модальное окно создания. Проект создан из шаблона по умолчанию, как в предыдущем уроке, примеры сброшены, добавлены иконки.
npx create-expo-app@latest notes-app --yes
cd notes-app
npm run reset-project
npx expo install @expo/vector-iconssrc/app/_layout.tsx
- Адрес
- —
- Роль
- Корневой стек
src/app/(tabs)/_layout.tsx
- Адрес
- —
- Роль
- Вкладки
src/app/(tabs)/index.tsx
- Адрес
/- Роль
- Список записей
src/app/(tabs)/about.tsx
- Адрес
/about- Роль
- О приложении
src/app/notes/[id].tsx
- Адрес
/notes/1,/notes/2, …- Роль
- Запись
src/app/new.tsx
- Адрес
/new- Роль
- Модальное окно
src/data/notes.tsx
- Адрес
- —
- Роль
- Данные, не маршрут
index.tsx
- Что значит
- Экран по умолчанию для папки
- Пример
(tabs)/index.tsx→/
[id].tsx
- Что значит
- Динамический сегмент: подходит под любое значение
- Пример
notes/[id].tsx→/notes/42
(tabs)/
- Что значит
- Группа: объединяет экраны, но не попадает в адрес
- Пример
(tabs)/about.tsx→/about
_layout.tsx
- Что значит
- Навигатор папки: как связаны её экраны
- Пример
Stack,Tabs
+not-found.tsx
- Что значит
- Экран для адресов, которым ничего не соответствует
- Пример
- Expo Router показывает свой, если файла нет
_layout: стек и вкладки
Корневой _layout.tsx заменяет App.tsx: он рендерится раньше всех экранов, поэтому сюда помещают провайдеры, шрифты и тему. Записи храним в React-контексте, чтобы список и модальное окно работали с одними данными. Сохранение между запусками — тема следующего урока, здесь данные живут в памяти.
import { createContext, use, useState, type ReactNode } from 'react';
export type Note = {
id: string;
title: string;
body: string;
};
type NotesContextValue = {
notes: Note[];
addNote: (title: string, body: string) => Note;
};
const NotesContext = createContext<NotesContextValue | null>(null);
const initialNotes: Note[] = [
{ id: '1', title: 'Call the supplier', body: 'Confirm the delivery date for Friday.' },
{ id: '2', title: 'Team meeting', body: 'Prepare the agenda and the weekly report.' },
];
export function NotesProvider({ children }: { children: ReactNode }) {
const [notes, setNotes] = useState(initialNotes);
function addNote(title: string, body: string) {
const note = { id: String(Date.now()), title, body };
setNotes((current) => [note, ...current]);
return note;
}
return <NotesContext value={{ notes, addNote }}>{children}</NotesContext>;
}
export function useNotes() {
const value = use(NotesContext);
if (!value) {
throw new Error('useNotes must be used inside NotesProvider');
}
return value;
}import { Stack } from 'expo-router';
import { NotesProvider } from '@/data/notes';
export default function RootLayout() {
return (
<NotesProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="notes/[id]" options={{ title: 'Note' }} />
<Stack.Screen name="new" options={{ presentation: 'modal', title: 'New note' }} />
</Stack>
</NotesProvider>
);
}Stack — стек экранов: новый экран ложится сверху, кнопка или жест «назад» снимает его. Stack.Screen здесь не создаёт маршруты (их создают файлы), а настраивает их: заголовок, видимость шапки, способ показа. Группа (tabs) — один экран корневого стека, внутри которого свой навигатор.
import Ionicons from '@expo/vector-icons/Ionicons';
import { Link, Tabs } from 'expo-router';
import { Pressable, StyleSheet, Text } from 'react-native';
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#2563eb' }}>
<Tabs.Screen
name="index"
options={{
title: 'Notes',
tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} />
),
headerRight: () => (
<Link href="/new" asChild>
<Pressable accessibilityRole="button" style={styles.headerButton}>
<Text style={styles.headerButtonText}>New</Text>
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="about"
options={{
title: 'About',
tabBarIcon: ({ color, size }) => (
<Ionicons name="information-circle-outline" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
headerButton: {
marginRight: 16,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: '#2563eb',
},
headerButtonText: {
color: '#ffffff',
fontWeight: '600',
},
});Tabs из expo-router — JavaScript-вкладки: одинаково выглядят на всех платформах и гибко настраиваются через screenOptions (для всех вкладок) и options (для одной). Иконка получает color и size от навигатора, поэтому активная вкладка подсвечивается сама. Чтобы спрятать маршрут из панели вкладок, ему задают href: null.
Link и router: переходы между экранами
Декларативный переход — компонент Link. По умолчанию он оборачивает содержимое в Text; с asChild передаёт нажатие дочернему компоненту, например Pressable со своими стилями. Адрес можно задать строкой (/notes/1) или объектом с pathname и params — второй вариант удобнее, когда значение берётся из данных.
import { Link } from 'expo-router';
import { FlatList, Pressable, StyleSheet, Text } from 'react-native';
import { useNotes } from '@/data/notes';
export default function NotesScreen() {
const { notes } = useNotes();
return (
<FlatList
data={notes}
keyExtractor={(note) => note.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<Text style={styles.empty}>No notes yet</Text>}
renderItem={({ item }) => (
<Link href={{ pathname: '/notes/[id]', params: { id: item.id } }} asChild>
<Pressable style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.preview} numberOfLines={1}>
{item.body}
</Text>
</Pressable>
</Link>
)}
/>
);
}
const styles = StyleSheet.create({
list: {
padding: 16,
gap: 12,
},
row: {
padding: 16,
borderRadius: 12,
backgroundColor: '#ffffff',
gap: 4,
},
title: {
fontSize: 17,
fontWeight: '600',
color: '#18181b',
},
preview: {
fontSize: 15,
color: '#71717a',
},
empty: {
textAlign: 'center',
color: '#71717a',
},
});import { Link } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
export default function AboutScreen() {
return (
<View style={styles.container}>
<Text style={styles.text}>Notes is a demo of Expo Router: tabs, a stack and a modal.</Text>
<Link href="/notes/1" style={styles.link}>
Open the first note
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
gap: 16,
},
text: {
fontSize: 16,
lineHeight: 24,
color: '#3f3f46',
},
link: {
fontSize: 16,
color: '#2563eb',
},
});navigate(href)
- Что делает
- Открывает экран или возвращается к нему, если он уже в стеке
- Когда нужен
- Обычный переход — вариант по умолчанию
push(href)
- Что делает
- Всегда кладёт новый экран, даже если такой уже есть
- Когда нужен
- Цепочка однотипных экранов: запись → связанная запись
replace(href)
- Что делает
- Подменяет текущий экран без записи в историю
- Когда нужен
- После входа в аккаунт, после сохранения формы
back() / canGoBack()
- Что делает
- Шаг назад / есть ли куда возвращаться
- Когда нужен
- Своя кнопка «назад»
dismissTo(href)
- Что делает
- Закрывает экраны до указанного, иначе заменяет текущий
- Когда нужен
- Закрыть модальное окно и вернуться к списку
setParams(params)
- Что делает
- Меняет параметры текущего экрана без новой записи в истории
- Когда нужен
- Поиск и фильтры в адресе
В веб-сборке Link превращается в настоящую ссылку: в нашей проверке кнопка «New» в браузере отрисовалась как <a href="/new">. Её можно открыть в новой вкладке, а переход идёт без перезагрузки страницы — состояние JavaScript сохраняется.
Динамический маршрут и параметры
Файл notes/[id].tsx обслуживает любые адреса вида /notes/<id>. Значение сегмента возвращает хук useLocalSearchParams — типизируем его дженериком. Параметры адреса всегда строки (у сегментов вида [...rest] — массивы строк), поэтому числа и даты разбирают явно.
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useNotes } from '@/data/notes';
export default function NoteScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { notes } = useNotes();
const router = useRouter();
const note = notes.find((item) => item.id === id);
function goBack() {
if (router.canGoBack()) {
router.back();
} else {
router.replace('/');
}
}
return (
<View style={styles.container}>
<Stack.Screen options={{ title: note?.title ?? 'Not found' }} />
<Text style={styles.body}>{note ? note.body : `Note ${id} does not exist.`}</Text>
<Pressable accessibilityRole="button" onPress={goBack} style={styles.button}>
<Text style={styles.buttonText}>Back to notes</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
gap: 24,
},
body: {
fontSize: 17,
lineHeight: 26,
color: '#18181b',
},
button: {
alignSelf: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#e4e4e7',
},
buttonText: {
fontSize: 16,
color: '#18181b',
},
});Заголовок из данных
<Stack.Screen options={...} />внутри экрана меняет его шапку, когда данные уже известны, — так в заголовке оказывается название записи, а не общее «Note».Неизвестный id
Запись могли удалить, а ссылка осталась. Экран не падает, а показывает понятное сообщение.
Возврат без истории
Если экран открыт по глубокой ссылке, предыдущего экрана в стеке нет и
router.back()вернуться не сможет. Поэтому сначала проверяемrouter.canGoBack(), а иначе заменяем экран на список.Локальные и глобальные параметры
useLocalSearchParamsобновляется, только когда адрес относится к этому экрану, и не перерисовывает экраны, оставшиеся ниже в стеке.useGlobalSearchParamsследит за любым адресом — документация предупреждает о лишних перерисовках.
Модальное окно создания записи
Модальный экран — обычный маршрут, которому в стеке задали presentation: 'modal' (это сделано в корневом _layout.tsx). На iOS он выезжает снизу и закрывается свайпом вниз, на Android закрывается кнопкой «назад». В вебе документация требует дать пользователю способ закрыть окно самостоятельно — для этого кнопка «Cancel».
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useNotes } from '@/data/notes';
export default function NewNoteScreen() {
const router = useRouter();
const { addNote } = useNotes();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const canSave = title.trim().length > 0;
function save() {
addNote(title.trim(), body.trim());
router.dismissTo('/');
}
return (
<View style={styles.container}>
<TextInput
placeholder="Title"
value={title}
onChangeText={setTitle}
style={styles.input}
autoFocus
/>
<TextInput
placeholder="Text"
value={body}
onChangeText={setBody}
style={[styles.input, styles.multiline]}
multiline
/>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: !canSave }}
disabled={!canSave}
onPress={save}
style={[styles.button, !canSave && styles.buttonDisabled]}>
<Text style={styles.buttonText}>Save</Text>
</Pressable>
<Pressable accessibilityRole="button" onPress={() => router.dismissTo('/')}>
<Text style={styles.cancel}>Cancel</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
gap: 12,
},
input: {
borderWidth: 1,
borderColor: '#d4d4d8',
borderRadius: 10,
padding: 12,
fontSize: 16,
},
multiline: {
minHeight: 120,
textAlignVertical: 'top',
},
button: {
alignItems: 'center',
padding: 14,
borderRadius: 10,
backgroundColor: '#2563eb',
},
buttonDisabled: {
opacity: 0.4,
},
buttonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
cancel: {
textAlign: 'center',
padding: 8,
color: '#71717a',
},
});router.dismissTo('/') закрывает экраны, пока не дойдёт до списка. Если окно открыто напрямую по адресу /new и списка в истории нет, метод заменит текущий экран на список — отдельная проверка, как в экране записи, не нужна.
Глубокие ссылки
Глубокая ссылка открывает конкретный экран приложения. Схема задаётся полем scheme в app.json — в нашем проекте шаблон уже записал "scheme": "notesapp". Expo Router включает глубокие ссылки для всех экранов автоматически: notesapp://notes/1 откроет экран записи с id = "1", ничего дополнительно описывать не нужно. После добавления или смены схемы приложение пересобирают.
# development build или эмулятор с установленным приложением
npx uri-scheme open notesapp://notes/1 --android
npx uri-scheme open notesapp://notes/1 --ios
# Expo Go: путь внутри приложения идёт после /--/
exp://127.0.0.1:8081/--/notes/1- Ссылки со своей схемой не работают, если приложение не установлено. Для этого случая есть универсальные ссылки — обычные адреса
https://, которые открывают приложение или сайт. Android называет их App Links, iOS — Universal Links; обе требуют файла подтверждения на вашем домене. - Экран, открытый по ссылке, оказывается первым в стеке. Поэтому кнопка «назад» на экране записи проверяет
canGoBack(). - Если приложение публикуется и как сайт со статическим экспортом (
"output": "static"в шаблоне), для динамических маршрутов нужна функцияgenerateStaticParamsсо списком известных значений — иначе страницы/notes/2в сборке не будет.
Типизированные маршруты, тесты и упражнения
В шаблоне включён флаг experiments.typedRoutes: Expo генерирует типы всех маршрутов, и TypeScript проверяет каждый href. Документация называет функцию бета-версией. Типы появляются при запуске npx expo start, а без запуска сервера — после npx expo customize tsconfig.json. Мы проверили, что ошибки действительно ловятся: опечатка в пути и неверное имя параметра.
<Link href="/note/1">Typo in the path</Link>
<Link href={{ pathname: '/notes/[id]', params: { noteId: '1' } }}>Wrong param</Link>$ npx tsc --noEmit
src/tmp/typed-check.tsx(6,13): error TS2322: Type '"/note/1"' is not assignable to type '"/" | RelativePathString | ExternalPathString | "/new" | …
src/tmp/typed-check.tsx(7,46): error TS2322: Type '{ noteId: string; }' is not assignable to type 'UnknownInputParams & { id: string | number; }'.
Property 'id' is missing in type '{ noteId: string; }' but required in type '{ id: string | number; }'.Навигацию можно проверить без телефона: expo-router/testing-library рендерит приложение из папки маршрутов с заданным начальным адресом. Тест ниже проходит путь «список → запись → назад», открывает запись по глубокой ссылке, создаёт запись через модальное окно и проверяет запасной экран для неизвестного id.
/// <reference types="jest" />
import { router } from 'expo-router';
import { act, fireEvent, renderRouter, screen } from 'expo-router/testing-library';
const appDir = './src/app';
describe('Notes navigation', () => {
test('list → note details → back', async () => {
const app = renderRouter(appDir, { initialUrl: '/' });
await app;
expect(app.getPathname()).toBe('/');
await fireEvent.press(screen.getByText('Call the supplier'));
expect(app.getPathname()).toBe('/notes/1');
expect(app.getSearchParams()).toEqual({ id: '1' });
expect(screen.getByText('Confirm the delivery date for Friday.')).toBeTruthy();
await fireEvent.press(screen.getByRole('button', { name: 'Back to notes' }));
expect(app.getPathname()).toBe('/');
});
test('deep link opens a note without history', async () => {
const app = renderRouter(appDir, { initialUrl: '/notes/2' });
await app;
expect(app.getPathname()).toBe('/notes/2');
expect(screen.getByText('Prepare the agenda and the weekly report.')).toBeTruthy();
await fireEvent.press(screen.getByRole('button', { name: 'Back to notes' }));
expect(app.getPathname()).toBe('/');
});
test('modal creates a note and closes', async () => {
const app = renderRouter(appDir, { initialUrl: '/' });
await app;
await act(() => router.push('/new'));
expect(app.getPathname()).toBe('/new');
await fireEvent.changeText(screen.getByPlaceholderText('Title'), 'Buy coffee beans');
await fireEvent.press(screen.getByRole('button', { name: 'Save' }));
expect(app.getPathname()).toBe('/');
expect(screen.getByText('Buy coffee beans')).toBeTruthy();
});
test('unknown id shows a fallback', async () => {
await renderRouter(appDir, { initialUrl: '/notes/999' });
expect(screen.getByText('Note 999 does not exist.')).toBeTruthy();
});
});$ npm test
PASS __tests__/navigation-test.tsx
Notes navigation
√ list → note details → back (352 ms)
√ deep link opens a note without history (29 ms)
√ modal creates a note and closes (70 ms)
√ unknown id shows a fallback (10 ms)
Tests: 4 passed, 4 totalТе же сценарии мы прошли в браузере на веб-сборке: список, запись, возврат, модальное окно с сохранением, вкладка «About» и прямой заход по адресу /notes/2. На телефоне и эмуляторе урок не запускался — это ваш шаг.
- Компонент или хук лежит в
src/appи появился как лишний маршрут — перенесите его вsrc/componentsилиsrc/data router.back()ничего не делает после глубокой ссылки — проверяйтеcanGoBack()или используйтеdismissTo- Параметр сравнивают с числом и не находят запись — параметры адреса всегда строки
- Ссылка со схемой не открывает приложение — после добавления
schemeприложение нужно пересобрать tscне видит типов маршрутов — запуститеnpx expo startилиnpx expo customize tsconfig.json
Дальше по траектории React Native: загрузка данных с API и их хранение на устройстве, чтобы записи не пропадали после перезапуска.
Как проверено5 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
Проект notes-app: src/data/notes.tsx, src/app/_layout.tsx, (tabs)/_layout.tsx, (tabs)/index.tsx, (tabs)/about.tsx, notes/[id].tsx, new.tsx
create-expo-app@latest --yes (expo 57.0.27, expo-router 57.0.25, react-native 0.86.3, react 19.2.3, typescript 6.0.3) + reset-project + npx expo install @expo/vector-icons (15.1.1); npx expo customize tsconfig.json; tsc --noEmit OK; expo-doctor 21/21; npx expo export --platform web (8 статических маршрутов) / android / ios — бандлы собраны; код статьи считан генератором из файлов проекта
- запущено, работает
__tests__/navigation-test.tsx (renderRouter, jest-expo 57.0.5, RNTL 14.1.0)
npm test: 4 passed — список → запись → назад, глубокая ссылка без истории → назад на /, модальное окно → сохранение → /, неизвестный id; вывод в статье реальный
- запущено, работает
Типизированные маршруты (ошибочный href и params)
временный src/tmp/typed-check.tsx: tsc выдал TS2322 на '/note/1' и на params { noteId }; файл удалён, повторный tsc чистый; вывод в статье реальный, список путей сокращён
- запущено, работает
Веб-сборка в браузере (playwright-core + headless Edge, 390×844)
dist-web на локальном node-сервере (для /notes/* — отдача шаблона notes/[id].html, имитация rewrite): клик по записи → /notes/1 без перезагрузки (метка в window сохранилась), Back → /, кнопка New = <a role=link href=/new> → /new, сохранение → / и новая запись в списке, history back, вкладка About → /about, Link '/notes/1', прямой заход /notes/2 → Back → /. Ошибка гидратации React #418 на прямом заходе — следствие статического экспорта без generateStaticParams и rewrite в тестовом сервере (в статье — пункт про generateStaticParams)
- запущено, работает
Упражнения: вкладка Settings, replace после сохранения, поиск через setParams
решения временно применены в проекте: tsc OK + 3 теста renderRouter (маршрут /settings; /new → сохранение → /notes/<id> → назад → /; ?q=team фильтрует, changeText → params { q: 'call' }); затем исходные файлы восстановлены и полный прогон повторён
- не запускалось
Запуск на устройстве/эмуляторе, npx uri-scheme open, Expo Go exp://…/--/, нативные анимации модального окна
нет устройства и эмулятора на машине; команды и поведение платформ — по документации Expo, в статье явно сказано, что на телефоне урок не запускался
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.