Экраны на useState и почему это ломается

навигация≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

«Подход» перестаёт быть одним экраном: из справочника открывается карточка упражнения, из сегодняшней тренировки — тоже. Самый короткий путь напрашивается сам и знаком по части IV: имя экрана — в состояние, нужная ветка — в разметку. На паре экранов это работает. В этой статье мы его не чиним, а смотрим, как именно он ломается на телефоне: пропадает история, системная кнопка «назад» уводит из приложения, а список забывает, докуда его долистали. Каждую поломку увидим в консоли — из них и сложится список того, что должен уметь маршрутизатор, который глава соберёт своими руками.

Разделы темы:·

В браузере у второго экрана был адрес: ссылка, адресная строка, история вкладки. В React Native ничего этого нет — экран здесь просто то, что сейчас нарисовано в корне приложения. Поэтому первое решение напрашивается само: завести состояние screen и рисовать ту ветку, которая в нём записана. Нажатие на строку справочника — Pressable с onPress, как в главе про ввод; сам справочник — сорок упражнений в подложке, короткая копия того, что собирала глава про списки.

Чтобы видеть, что происходит с экранами, каждый из них отмечается в эффекте: появился — ушёл. Прогноз: нажимаем на «Приседания со штангой», потом на «‹ Назад». Сколько раз справочник появится на экране и сколько раз уйдёт?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Появился дважды, ушёл один раз. Когда открылась карточка, ветка справочника исчезла из дерева, и React размонтировал его целиком — ровно по правилу из статьи «Когда состояние сохраняется, а когда сбрасывается» части IV. «‹ Назад» не вернула прежний справочник: она построила новый.

На телефоне за этим размонтированием стоит больше, чем ячейки состояния. Каждый View, Text и ScrollView здесь — настоящий нативный вид: на iOS это UIView и его родня, на Android — android.view.View. Размонтированный компонент уносит с собой свои виды, а вместе с ними всё, что вид хранит сам, не спрашивая React. Держите это в голове до раздела про прокрутку — там это станет главным.

Нажатия в примере разнесены таймерами не для красоты: «‹ Назад» существует только после того, как React отрисует карточку. Нажмите обе кнопки в одном такте — и второй fireEvent не найдёт кнопку вовсе.

Добавим то, без чего «Подход» не нужен: вкладку с сегодняшней тренировкой. Карточку жима теперь открывают из двух мест — из справочника и из тренировки. Экраны переехали в подложку: Catalog и Card — те же, что в первом примере, только без строк в консоли, а Workout — новый, список из четырёх упражнений сегодняшней тренировки. Строку печатает сам App — при каждой смене экрана.

Прогноз: человек в тренировке открыл жим лёжа и нажал «‹ Назад». Где он окажется?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

В справочнике — хотя пришёл из тренировки. Код при этом не ошибся: он сделал ровно то, что написано, — setScreen({ name: "catalog" }). Ошибка в самой форме состояния. В нём лежит одно значение — текущий экран, а где человек был до этого, не записано нигде, и карточке просто не у кого спросить.

Поле «откуда» в объект карточки добавить можно — это первая задача в конце статьи. Но стоит пройти на экран глубже, и «откуда» самого «откуда» тоже придётся хранить: карточка → похожее упражнение → его карточка → назад → назад. Это уже не поле, а список прошлых экранов. У него есть имя — история.

В браузере историю вёл браузер. Свой роутер из статьи «Зачем фреймворк поверх React» опирался на history.pushState и событие popstate, а переходы назад и вперёд делали кнопки браузера. Прогноз: что скажет компилятор про эту строку и что напечатает песочница?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Компилятор и рантайм разошлись, и про телефон прав компилятор. Типы в этих примерах — из пакета react-native и без библиотеки DOM, поэтому имени history для них нет: в React Native журнала переходов не существует. Песочница же — браузер с react-native-web внутри; объект там нашёлся, и Platform.OS честно отвечает "web". На телефоне тот же typeof вернёт "undefined": ни истории, ни адресной строки, ни кнопок браузера там нет, и завести их некому, кроме вас или библиотеки навигации.

Есть — и это известная ловушка. React Native при запуске сам кладёт window вторым именем глобального объекта: в Libraries/Core/setUpGlobals.js стоит проверка «если global.window не определён — присвоить ему global». Поэтому typeof window !== "undefined", которым библиотеки отличают браузер от сервера, на телефоне ошибочно отвечает «браузер». Типы react-native такого имени не объявляют, и в песочнице компилятор скажет про window то же, что про history. А вот history среди глобалей, которые React Native подкладывает при запуске (fetch, URL, FormData, WebSocket и ещё с десяток), нет.

«‹ Назад» в карточке — ваша кнопка: нарисовали, повесили onPress. Но у человека с телефоном есть и чужая. На Android это системная кнопка или жест от края экрана, и жмут её не глядя, по привычке. На iOS системной кнопки нет, зато есть свайп от левого края и кнопка в шапке — обе даёт навигационный контроллер системы, а не ваш код.

До JavaScript системная «назад» доезжает событием hardwareBackPress, а слушают его через BackHandler — модуль самого React Native, нижний этаж; Expo здесь ни при чём. В песочнице он тоже есть. Прогноз: что напечатает попытка подписаться?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первая строка — подлинное сообщение react-native-web, красным, как любая ошибка: в браузере системной кнопки нет, поэтому addEventListener там только ругается и возвращает пустую отписку, а exitApp — пустая функция. Нажать системную «назад» в песочнице нечем, и поведение телефона придётся собрать моделью. Правила возьмём из исходника react-native 0.86, Libraries/Utilities/BackHandler.android.js: подписчики хранятся списком, при нажатии их опрашивают с конца, первый вернувший true забирает нажатие себе, а если не ответил никто — вызывается BackHandler.exitApp().

Прогноз: у App из первого примера подписок нет. Человек открыл карточку и нажал системную «назад» — что будет?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Никто не ответил — значит, нажатие возвращается системе. Вот что происходит с ним на Android по исходникам 0.86:
ШагГдеЧто происходит
1система Androidкнопка или жест «назад» приходят в активность приложения — ReactActivity
2ReactActivity.onBackPressedнажатие уезжает в JavaScript событием hardwareBackPress
3BackHandler.android.jsподписчики опрашиваются с последнего; первый true останавливает обход
4там же, если все промолчалиBackHandler.exitApp() зовёт нативный invokeDefaultBackPressHandler
5ReactActivity.invokeDefaultOnBackPressedсрабатывает обычное поведение Android для «назад» — экран приложения закрывается

Для человека это выглядит так: он был в карточке жима, нажал «назад» и оказался на рабочем столе телефона. В приложении два экрана, но для системы экран один — активность, в которой нарисован весь React. Всё, что вы переключаете состоянием, система не видит, и вернуть человека на справочник ей нечем.

Подписаться на BackHandler самому можно — это вторая задача. Только обработчику придётся решать тот же вопрос, что и кнопке в карточке: куда возвращаться. И ответ снова упрётся в историю, которой нет.

Свайп от левого края на iOS — такая же «чужая» назад: его даёт UINavigationController, в который нативный стек навигатора кладёт экраны по одному. Экраны на useState живут в одном нативном виде, и свайпать там нечего.

Третья поломка тише двух первых, и замечают её не в тесте, а в зале: человек долистал справочник до становой тяги, открыл её, вернулся — а список снова начинается с приседаний. Проверим не глазами, а замером. ref на компонент React Native отдаёт не DOM-узел, а нативный вид, и у вида есть measureInWindow: он сообщает в колбэк, где вид находится на экране. В песочнице под ref лежит div, которому react-native-web добавил тот же метод, так что замер работает и здесь. Меряем первую строку относительно верха списка — до перехода и после возврата.

Список прокручен на 720 точек — это двадцать строк по 36. Прогноз: какие два числа напечатает замер?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

До перехода первая строка уехала вверх ровно на прокрученное расстояние; после возврата она снова на месте, будто списка никто не касался. И это правда: того списка больше нет. Позиция прокрутки — не состояние React, её не было ни в одной ячейке, её держит сам нативный вид — UIScrollView на iOS, ScrollView на Android. Размонтировали компонент — уничтожили вид, а новый, построенный по «‹ Назад», начал с нуля.

В браузере здесь выручал сам браузер: при переходе назад по обычным страницам он восстанавливает прокрутку документа, а в одностраничном приложении этим занимается роутер — пункт «прокрутка» стоял в списке обязанностей своего роутера ещё в «Зачем фреймворк поверх React». На телефоне нет ни документа, ни роутера.

Прокрутка — самое заметное, но не единственное. Вместе с экраном пропадает всё, что жило в нём: состояние его компонентов, загруженные данные, наполовину заполненная форма подхода, фокус в поле ввода. Спасать это по кусочку, поднимая каждое значение в App, можно — третья задача делает так с прокруткой, — но кусочков у настоящего экрана десятки.

Мысль правильная: оставить справочник в дереве и только спрятать его, пока открыта карточка. Вид не уничтожится, прокрутка и состояние уцелеют. Но если прятать так всё, что человек открывал за сессию, в памяти телефона будут жить все экраны со всеми их эффектами и подписками. Нужна дисциплина: держать смонтированными ровно те экраны, по которым можно вернуться назад, и сносить тот, с которого назад ушли. Эта дисциплина называется стеком, и мы соберём его в статье «Собираем стек экранов с историей».

Сведём увиденное в одну таблицу. Каждая строка — не жалоба на useState, а требование к тому, что придёт ему на смену.

Что сломалосьПочемуЧто должен уметь навигаторГде в главе
«‹ Назад» ведёт не тудав состоянии только текущий экранпомнить историю — список экранов, а не один«Собираем стек экранов с историей»
системная «назад» закрывает приложениена hardwareBackPress никто не подписанотвечать BackHandler самому и отдавать нажатие системе только с первого экранатам же
список забыл прокрутку, форма — вводэкран размонтирован, его нативные виды уничтоженыдержать нижние экраны смонтированнымитам же
на экран не попасть снаружиу экрана нет имени-адресаадрес у каждого экрана и подбор экрана по адресу«Собираем маршруты» и «Ссылки снаружи»

Последнюю поломку в этой статье мы не видели — она проявится, когда уведомление «отдых кончился» должно будет открыть карточку упражнения: экран, у которого нет имени, снаружи не назвать. Имя экрана — строка вроде /exercise/squat — нужно и истории: хранить в ней удобнее адреса, чем деревья компонентов.

В настоящем проекте всё это делает готовая библиотека — в «Подходе» это expo-router, этаж Expo; его разберёт статья «expo-router: те же файлы, но со стеком». В песочнице он не соберётся: expo-modules-core тянет TurboModuleRegistry, которого у react-native-web нет. Может, что-то навигационное есть в самом react-native-web? Прогноз: сколько у него экспортов и какие из них совпадут с образцом?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Шестьдесят один экспорт, и единственное совпадение — Linking: он про внешние адреса — открыть сайт, принять ссылку, пришедшую в приложение, — а не про экраны; к нему глава вернётся в последней статье. Ни стека, ни экранов, ни истории нет ни в react-native-web, ни в самом React Native 0.86: навигацию из ядра давно вынесли в библиотеки. Поэтому следующие три статьи собирают маршрутизатор своими руками — путь файла как адрес, раскладки, стек с историей, — и он проживёт в подложке до конца части: без него у «Подхода» в песочнице не было бы переходов вовсе.

  • Экран в состоянии — это ветка условия: уходя, компонент размонтируется целиком, как в части IV, а на телефоне вместе с ним уничтожаются и его нативные виды.
  • Состояние помнит только текущий экран. Откуда пришли, не записано нигде, поэтому «‹ Назад» в карточке, открытой из двух мест, ведёт не туда; честное решение — список прошлых экранов, то есть история.
  • Истории браузера в React Native нет: history нет ни в типах, ни на телефоне, а песочница находит его только потому, что сама браузер.
  • Системную «назад» на Android слушает BackHandler: подписчики опрашиваются с конца, и если никто не вернул true, exitApp() отдаёт нажатие системе — человек оказывается на рабочем столе. В песочнице BackHandler — заглушка и говорит об этом сама.
  • Позицию прокрутки хранит нативный вид, а не React; ref отдаёт этот вид с measureInWindow, и замер показывает, что после возврата список снова наверху.
  • Навигатор должен помнить историю, отвечать на системную «назад» и держать нижние экраны смонтированными. Ни в react-native-web, ни в React Native его нет, поэтому глава соберёт его сама.

1/4Экраны переключаются через useState, на BackHandler никто не подписан. Человек на Android открыл карточку и нажал системную «назад». Что произойдёт?

2/4Список прокрутили до середины, открыли карточку и вернулись. Почему список снова наверху?

3/4В песочнице console.log(typeof history) печатает object, а компилятор пишет «Не удается найти имя "history"». Кто прав насчёт телефона?

4/4Карточку открывают из справочника и из тренировки, в состоянии App лежит { name: "card", id }. Чего не хватает кнопке «‹ Назад»?

«Назад» — туда, откуда пришли

Сделайте так, чтобы «‹ Назад» возвращала на ту вкладку, с которой открыли карточку. Меняйте только тип Screen и App; экраны лежат в подложке, как в статье.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Карточке нужно поле «откуда», и заполнять его надо в момент открытия: только тогда известно, какой экран сейчас на месте.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Последняя строка становится «экран: workout». Теперь добавьте в карточку «похожее упражнение», которое открывает другую карточку: from придётся расширить с Tab до Screen, и тип станет рекурсивным — карточка, у которой from — карточка, у которой from — тренировка. Это односвязный список прошлых экранов, история, записанная задом наперёд. Развернуть её в массив и назвать стеком — ровно то, чем займётся статья «Собираем стек экранов с историей». Прокрутку справочника from всё равно не спасает: экран, на который вернулись, строится заново.

Системная «назад» для экранов на состоянии

В подложке — модель backHandler и pressSystemBack из статьи. Подпишите App так, чтобы с карточки системная «назад» возвращала в справочник, а со справочника — отдавалась системе. Системную кнопку жмут дважды: сейчас «приложение закрывается» уже с карточки, а должно — только со справочника.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Подписка — эффект, отписка — его уборка:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод становится таким: справочник, карточка, нажатие, снова «экран: справочник», второе нажатие и только тогда exitApp(). Эффект обязан стоять выше if с ранним return — иначе на экране карточки хук не вызовется, и это нарушение правил хуков из части IV. В зависимостях screen: обработчик его читает, и без переподписки видел бы тот экран, на котором подписался. На телефоне код тот же, только вместо модели — BackHandler из react-native. Слабое место прежнее: куда возвращаться, обработчик знает из зашитой строки, и на третьем уровне экранов ему понадобится та же история, что кнопке в карточке. Порядок опроса с конца, наоборот, работает на вас: подписка, сделанная позже, спрашивается раньше — так навигатор и перехватывает кнопку раньше остального приложения.

Вернуть прокрутку руками

Позиция списка уже сохраняется: onScroll пишет её в savedY, а savedY живёт в App, который с экрана не уходит. Осталось вернуть список на место, когда он появится снова. Сейчас после возврата первая строка на 0, а должна быть на -720.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Новому списку нужен свой ref на ScrollView и эффект при появлении:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первая строка снова на -720. Цена видна сразу. Эффект срабатывает уже после того, как новый список смонтирован, и между этими моментами он стоит наверху — если телефон успеет показать этот кадр, список дёрнется. Позицию придётся заводить для каждого списка отдельно. А всё остальное, что жило в экране, — ввод, загруженные данные, раскрытые секции — по-прежнему теряется. Надёжнее не восстанавливать уничтоженный вид, а не уничтожать его: держать справочник смонтированным под карточкой, как это делает стек.