loading.tsx, error.tsx и not-found.tsx

Next.js≈ 14 минут

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

Три файла с зарезервированными именами не добавляют в React ничего нового: loading.tsx — это <Suspense>, который вы писали руками, error.tsx — класс-граница из статьи про границы ошибок, not-found.tsx — та же граница, но для одной особенной ошибки. Вся разница в том, что обёртки расставляет фреймворк, и место, куда он их ставит, объясняет почти все неожиданности.

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

В статье про use() и Suspense ожидание перестало быть состоянием: вместо if (name === null) return <p>Загрузка…</p> появилась обёртка <Suspense fallback={…}>, и запасная разметка переехала из компонента наружу. Файл loading.tsx — это ровно та обёртка, только написанная не вами.

Положили app/blog/loading.tsx рядом со страницей — и Next обернул содержимое сегмента в <Suspense>, подставив в fallback экспортированный оттуда компонент. Соберём эту пару руками и посчитаем заодно, сколько раз при этом перерисуется раскладка.

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

Заглушка показалась один раз, а счётчик раскладки так и остался на единице: пока страница ждала данные, раскладка не перерисовывалась вовсе. Это видно и в кадре — слово «Блог» не мигнуло ни разу, подменялось только то, что ниже. Граница ожидания стоит внутри раскладки, поэтому меню, шапка и хлебные крошки остаются на экране.

Отсюда практическое правило: loading.tsx кладут туда, где меняется содержимое, а не в корень проекта. Один app/loading.tsx на всё приложение означает, что при каждом переходе гаснет вся страница целиком — включая навигацию, по которой читатель как раз собирался кликнуть дальше.

Где лежит loading.tsxЧто подменяется заглушкой
app/loading.tsxвсё приложение ниже корневой раскладки
app/blog/loading.tsxстраница блога и все сегменты под ней; меню блога остаётся
app/blog/[slug]/loading.tsxтолько карточка поста; список рядом не трогается
файла нетближайшая граница выше; если её нет — ждёт весь переход

loading.tsx не про «медленный интернет» вообще: заглушка показывается, пока сегмент ждёт свои данные. Если компонент ничего не ждёт, файл просто никогда не сработает — и это нормально.

Можно и написать: <Suspense> внутри страницы никто не запрещает, и для отдельного медленного виджета так и делают. Разница в том, что loading.tsx оборачивает сегмент снаружи — включая переход на этот адрес. Написанный внутри страницы Suspense появится только тогда, когда сама страница уже начала рисоваться; файл-заглушка успевает показаться раньше.

Второе отличие — вложенные сегменты. Обёртка из файла достаётся не только своей странице, но и всему, что лежит глубже: одного файла хватает на целый раздел.

В статье про границы ошибок пришлось написать класс: getDerivedStateFromError, состояние с ошибкой, запасная разметка в render. Этот класс никуда не делся — его пишет Next, а от вас нужен только компонент запасной разметки. Он и лежит в error.tsx.

Пропсы ему даёт фреймворк, и их три: пойманная ошибка, функция сброса reset и функция повтора retry. Начнём с первых двух — на них видно, как граница устроена; retry появится в следующем разделе, когда станет понятно, чего reset не умеет.

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

Строка "use client" в начале файла обязательна, и это не украшение: граница ошибок держит состояние и вешает onClick, а значит, обязана попасть в браузер. Серверный error.tsx Next не примет. Директива помечает файл, код которого уезжает в браузер; без неё компонент в App Router выполняется на сервере. Пока достаточно помнить, что этот файл всегда клиентский. Где проходит граница между сторонами и что она тянет за собой, разбирает статья «Граница 'use client' и почему она заразна» в следующей главе.

Типы пропсов писать не обязательно — Next их знает, — но написанные проверяются по-настоящему. Забыть reset в разметке проще, чем кажется.

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

Две диагностики, и обе честные. Вторая — про digest: в это поле Next кладёт хеш серверной ошибки, чтобы её можно было найти в журнале, но появляется оно не всегда. Подробности серверной ошибки в браузер не уезжают вовсе: пользователю видно обобщённое сообщение и вот этот код, а настоящий текст остаётся в логах сервера.

Теперь соберём то, что Next строит целиком: класс-граница снаружи, ваш компонент внутри.

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

Полтора десятка строк класса — и получился error.tsx. В настоящем проекте вы пишете только BlogError, всё остальное фреймворк держит при себе. Но знать, что там внутри, полезно: следующий раздел — про то, чего reset не умеет.

В прошлом примере кнопка сработала, потому что причину починили до нажатия. Уберём починку — и увидим, что reset делает на самом деле.

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

Каждое нажатие — новый заход на то же самое: граница забывает ошибку, React рисует детей заново, дети падают снова. reset не перезапрашивает данные и не чинит сервер, он лишь снимает состояние ошибки. Если причина не в случайной сетевой неудаче, кнопка будет мигать бесконечно.

Ровно для этого у error.tsx есть третий проп — retry(): внутри он и делает то, что пришлось бы писать руками, — refresh() роутера из статьи о переходах вместе со сбросом ошибки, одним переходом. Документация Next советует по умолчанию звать именно его, а reset оставить тем редким случаям, когда данные перезапрашивать не нужно.

И вторая половина той же мысли: состояние границы само не сбрасывается. Пока пользователь стоит на сломанном адресе, он видит error.tsx; при переходе на другой адрес сегмент размонтируется вместе с границей, и на новом месте всё начинается с чистого листа.

Все три файла — обёртки, и весь их характер задаётся местом в дереве. Для одного сегмента Next собирает такую матрёшку.

Слой матрёшки, снаружи внутрьИз какого файла
раскладка сегментаlayout.tsx
граница ошибокerror.tsx
граница ожиданияloading.tsx
граница «не найдено»not-found.tsx
содержимоеpage.tsx или следующий сегмент

Верхняя строка таблицы объясняет самую частую загадку: граница ошибок стоит внутри раскладки своего сегмента, а значит, ошибку этой раскладки поймать не может. Ловит её граница уровнем выше — из родительского сегмента.

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

Файл app/blog/error.tsx не участвовал вовсе: его граница лежит внутри упавшей раскладки, а значит, до монтирования дело не дошло. Сработала родительская — app/error.tsx. Шапка сайта при этом на экране: она из корневой раскладки, которая выше всех границ.

Это не недоработка, а замысел. Раскладка сегмента — общая для всех страниц раздела: меню, вкладки, хлебные крошки. Если бы её ошибку ловила граница того же сегмента, запасная разметка оказалась бы внутри сломанного меню. Ошибку принимает уровень, который заведомо цел.

Практический вывод для отладки: увидели чужой, «слишком общий» экран ошибки — проверьте, не упала ли раскладка. Ошибка страницы показывает error.tsx своего раздела, ошибка раскладки — родительский.

У корневой раскладки родителя нет, и ловить её ошибку некому — экран останется пустым. Для этого случая заводят app/global-error.tsx: он заменяет собой весь документ, поэтому обязан вернуть <html> и <body> сам — упавшая корневая раскладка их уже не отдаст.

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

Пример ничего не рисует: <html> внутрь страницы не смонтировать, а компилятор при этом отработал и пропсы проверил. В режиме разработки Next рисует поверх него свой оверлей с ошибкой: сам файл отрисован, но увидеть его таким, каким увидит посетитель, проще на собранном приложении.

Про 404 в статье про App Router уже говорилось: сегмент без page.tsx отвечает «страницы нет». Файл not-found.tsx отвечает на другой вопрос — что при этом показать. Срабатывает он в двух случаях: адрес не подошёл ни под один маршрут (тогда работает корневой app/not-found.tsx) или вы позвали notFound() сами.

Второй случай — рабочий: сегмент [slug] подходит под любое значение, но пост с таким адресом может не существовать. Функция notFound() из next/navigation ничего не возвращает — она бросает служебную ошибку, и это видно по её типу.

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

Тип возврата never здесь работает как в статье про полный разбор вариантов: компилятор понимает, что до return post.title с пустым post дойти невозможно, и сужение из части III делает остальное. Уберите : never — и на post.title тут же появится «Возможно, "post" имеет значение undefined».

А ловит эту ошибку отдельная граница — та, что в матрёшке стоит ниже границы ошибок. Поэтому обычное падение попадает в error.tsx, а notFound() — в not-found.tsx, и одно другому не мешает.

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

Разные экраны для разных бед: «такого поста нет» — это не поломка, а нормальный ответ приложения, и выглядеть он должен как страница, а не как авария. Ровно это разделение Next и приносит: одна ошибка попадает в свою границу, вторая пролетает мимо неё в error.tsx.

Тексты вроде NEXT_HTTP_ERROR_FALLBACK;404 — служебная кухня Next, наизусть их знать не нужно. Важно, что notFound() именно бросает: это обычный механизм исключений из части I, а не волшебство фреймворка.

Разница между «показать красивый экран» и «ответить 404» для поисковика огромна. notFound() закрывает обе: в разметку уезжает <meta name="robots" content="noindex">, и робот страницу в индекс не берёт. С кодом ответа тоньше: 404 достаётся только тем ответам, которые ещё не начали уходить по частям. Как только отрисовалась первая запасная разметка — то есть сработал loading.tsx из первого раздела, — заголовки уже отправлены, и ответ остаётся с кодом 200; из индекса такую страницу держит именно noindex. Поэтому notFound() зовут до первого await, который может приостановить отрисовку. А свой <p>Ничего не найдено</p> вместо вызова функции не даст ни кода, ни пометки — для поисковика это обычная страница с текстом, и она попадёт в выдачу.

Отсюда правило: если по адресу нечего показывать, зовите notFound(), а не рисуйте заглушку руками.

Первый — самый обидный: notFound() бросает ошибку, а вокруг стоит ваш try..catch. Служебная ошибка попадает в ваш catch, и 404 не случается.

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

Вместо страницы «такого поста нет» читатель получил бодрое «не удалось загрузить». Лечится двумя способами: звать notFound() уже после try..catch, по результату, или в catch пробрасывать чужое дальше, как это делала граница из прошлого раздела.

Остальные три собраны в таблицу — все они про место, а не про код.

СимптомПричина
На каждом переходе мигает вся страница вместе с менюloading.tsx лежит в корне app, а не в сегменте, содержимое которого меняется
Экран ошибки выглядит «не от того раздела»упала не страница, а layout.tsx сегмента: его ловит родительская граница
Ошибка в обработчике клика роняет весь экранграница ловит только рендер; обработчики, таймеры и промисы — мимо неё, как в статье про границы ошибок
not-found.tsx не показываетсяникто не позвал notFound(): сам по себе файл срабатывает только на несуществующем маршруте

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

  • loading.tsx — это <Suspense> вокруг содержимого сегмента: раскладка остаётся на экране, подменяется только то, что ниже.
  • error.tsx — граница ошибок из прошлой главы: класс пишет Next, от вас нужен компонент запасной разметки с пропсами error, reset и retry, и он всегда клиентский.
  • reset лишь стирает ошибку и рисует детей заново; данные он не перезапрашивает, поэтому при неустранённой причине экран ошибки возвращается сразу — за перезапрос отвечает retry.
  • Порядок обёрток внутри сегмента — раскладка, граница ошибок, ожидание, «не найдено», содержимое; отсюда правило, что error.tsx не ловит свой layout.tsx.
  • notFound() бросает служебную ошибку и объявлена как never: её ловит отдельная граница not-found.tsx, а ваш try..catch может перехватить её первым.
  • Каждый из этих файлов действует на свою папку и всё, что под ней, — значит, «показалось не то» чаще всего лечится переносом файла, а не правкой кода.

1/4В app/blog/layout.tsx при рендере выброшена ошибка. Рядом лежат и app/blog/error.tsx, и app/error.tsx. Что увидит пользователь?

2/4Пользователь нажал «Попробовать снова», а причина ошибки никуда не делась — сервер по-прежнему отдаёт битые данные. Что произойдёт?

3/4В app/blog лежат layout.tsx с меню блога, loading.tsx и медленная page.tsx. Что на экране, пока страница ждёт данные?

4/4Страница вызывает notFound() внутри блока try..catch, где ловятся ошибки загрузки. Что покажет приложение?

Соберите сегмент в правильном порядке

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

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

Порядок обёрток — это и есть ответ на вопрос «что останется на экране». Раскладка должна быть самой внешней, тогда её меню переживает и ожидание, и падение страницы.

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

Теперь во время ожидания в кадре «Блог» и «Загружаем…», а если страница упадёт — «Блог» и текст ошибки. Ровно эту матрёшку Next строит из четырёх файлов одной папки.

404 вместо аварии

Страница получает адрес несуществующего поста и падает на post.title. Пользователь видит экран ошибки, хотя произошло нормальное «такого поста нет». Почините функцию так, чтобы вместо падения срабатывал notFound() — и заодно чтобы компилятор перестал ругаться на post.

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

Одна строка с проверкой решает обе задачи сразу: и поведение, и диагностику.

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

Тип возврата never у notFound сообщает компилятору, что дальше выполнение не идёт, — и post ниже становится обычным Post. Без : never в сигнатуре сужение не сработало бы, и «Возможно, "post" имеет значение undefined» осталось бы на месте.

Граница, которая ловит лишнее

Эта граница «не найдено» присваивает себе любую ошибку: и служебную от notFound(), и настоящую поломку. Из-за этого настоящая ошибка выглядит как отсутствующая страница и никогда не доезжает до error.tsx. Почините render.

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

Граница проверяет, её ли это ошибка, и всё остальное бросает заново — падение внутри запасной разметки уходит следующей границе наружу.

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

Так и устроена пара границ в Next: внутренняя разбирает только служебную ошибку от notFound(), а всё прочее пролетает мимо неё в error.tsx.