Данные на сервере: список и страница задачи
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска из части II держала задачи в localStorage и собирала список руками. Теперь источник правды — сервер, и оба экрана проекта приезжают в браузер уже с данными: доска со всеми задачами и страница одной задачи. Кода в них немного, а решений — три, и каждое легко принять неправильно.
У двух экранов два главных вопроса: «какие задачи есть» и «что это за задача». Оба ответа живут в одном месте — lib/tasks.ts, — и ни один компонент не знает, что там внутри: база, файл или чужой сервис. Причина не в чистоте ради чистоты. В следующей статье эти же функции понадобятся действиям, а компонент, который сам собирает запрос к базе, придётся переписывать дважды.
Тип Task | undefined у getTask — не лень, а решение. Отсутствие задачи здесь ожидаемо: читатель мог открыть старую ссылку. Исключение приберегают для того, что не должно происходить, — упавшей базы, битого ответа. Разницу между «нет данных» и «всё сломалось» вы уже размечали типами в части III, и она никуда не делась.
| Вопрос экрана | Функция | Что возвращает |
|---|---|---|
| Какие задачи на доске | listTasks() | Task[] — пустой массив тоже ответ |
| Что за задача по этому адресу | getTask(id) | Task | undefined |
| Что с задачей происходило | listEvents(id) | TaskEvent[], но заметно медленнее |
Третья функция понадобится в разделе про медленную часть: она попала в проект именно потому, что тормозит.
Страница доски — серверный компонент со словом async, и больше в нём ничего необычного нет. Что делает с ним сервер, вы разбирали в статье «Данные прямо в компоненте»: зовёт как обычную функцию и ждёт промис. Проделаем это руками — и отрисуем то, что сервер получил на руки.
Ни состояния, ни эффекта, ни ветки «пока грузится». Раскладка по колонкам — обычный filter внутри map, потому что данные уже на руках: сервер сначала дождался их, и только потом начал собирать элементы. И key здесь — task.id, а не индекс: задачи будут переезжать между колонками, а с индексом React перепутает карточки (об этом была статья про списки в разметке).
Карточку из li в проекте выносят в отдельный компонент — и вот тут начинается то, чего в части II не было вовсе. Пропсы проверяются на сборке, а не при клике.
Две диагностики — и обе про то, что раньше выяснялось на живом экране. В доске из части II карточка собиралась строкой, и опечатка в имени поля давала undefined в разметке: пустая строчка вместо заголовка, и ищи, откуда. Здесь компилятор называет и место, и причину.
Вторая диагностика говорит о «свойстве, которого нет в типе», и это не оговорка: <TaskCard task={task} /> компилируется в вызов функции с объектом пропсов, так что забытый href — ровно недостающее свойство объекта.
Адрес карточки — /board/t2, файл — app/board/[id]/page.tsx. Значение сегмента приходит пропсом params, и с Next 15 это промис, а не объект: об этом была статья «Динамические сегменты и асинхронные params». Написать по привычке params.id легко, и вот что на это скажет компилятор.
Ошибка эта живучая: без объявленного типа пропсов она дожила бы до запуска и обернулась загадочным undefined в запросе к базе — искать пришлось бы по логам. С типом на неё уходит одна строка диагностики на сборке.
С await всё встаёт на места. Соберём страницу целиком и позовём её так, как позовёт Next.
Два await подряд здесь не образуют водопада: первый ждёт не сеть, а фреймворк, второй — базу. Но стоит добавить третий, за лентой событий, и получится водопад из статьи про await в компоненте: страница подождёт сумму, а не самое долгое. Проверка простая — зависит ли следующий запрос от предыдущего. За событиями задачи нужен id, а он приходит из адреса, не из базы, значит оба запроса могут стартовать рядом.
Next хочет начать сборку страницы до того, как узнает значения сегментов, — тогда общую для всех адресов часть можно отдать заранее, а подставить конкретный id позже. Синхронное свойство такой отсрочки не допускает, промис допускает. Отсюда же searchParams: он тоже асинхронный и по той же причине.
Строка task?.title ?? "Задача не найдена" из прошлого раздела — заглушка, и плохая: страница ответит кодом 200, поисковик проиндексирует несуществующую задачу, а читатель получит карточку-призрак с пустыми полями. Правильный ответ — notFound(). У него неожиданный побочный эффект: он чинит не только код ответа, но и типы.
Диагностика ровно одна, и она во второй половине. Обе функции бросают исключение одинаково, но компилятор читает не тела, а сигнатуры: never — обещание никогда не вернуть управление, и после такого вызова ветка task === undefined считается недостижимой. У void такого обещания нет, поэтому task.title остаётся опасным. Тип never вы разбирали в части III как «значения не бывает» — вот его самое практичное применение.
Сужение опирается на явно написанный тип и придирчиво к форме: у объявления function хватает «: never» в сигнатуре, а у стрелки в const аннотацию нужно ставить на саму переменную. Выведенный never не считается.
Теперь то же самое в странице. Файл app/board/[id]/not-found.tsx Next покажет сам, поймав служебную ошибку; ниже эта его работа расписана вручную, чтобы было видно, кто кого ловит.
Обратите внимание, где стоит вызов: в странице, а не в getTask. Соблазн спрятать его в слой данных велик — одной строкой на все места, — но слой данных зовут и действия из следующей статьи, а там 404 не при чём: форма должна получить внятную ошибку, а не редирект на «не найдено». И вторая причина — ловушка с try..catch из статьи про not-found.tsx: обёрнутый в catch вызов notFound() превращается в обычную пойманную ошибку, и 404 не случается.
| Где зовут notFound() | Что получится |
|---|---|
| В странице, после проверки результата | 404 и not-found.tsx — то, что нужно |
В lib/tasks.ts, внутри выборки | действие из формы тоже улетит в 404 вместо сообщения об ошибке |
Внутри своего try..catch | служебная ошибка попадёт в ваш catch, страница ответит 200 |
У задачи есть лента событий: кто создал, кто перенёс, когда закрыли. Лежит она отдельно и отвечает медленно — пусть будет 300 мс против 30 у самой задачи. Написать напрашивается вот так.
Promise.all здесь не спасает: водопада действительно нет, оба запроса идут рядом, — но await всё равно стоит выше разметки, и до его конца в браузер не уходит ни байта. Заголовок был готов через 30 мс и всё равно прождал все 300. Лечится это не ускорением ленты, а переносом ожидания вниз: пусть ждёт тот, кто её показывает.
Граница стоит выше await ленты и ниже await задачи — именно поэтому она работает. Что при этом видит читатель, посмотрим в кадре. Прикиньте заранее, что окажется на экране на сотой миллисекунде.
Заголовок оказался на экране сразу и остался на месте — его не перерисовало и не мигнуло. Подменилась только та часть, что стояла внутри границы. На сервере то же самое происходит с ответом: оболочка с заголовком уходит первым куском, лента дошлётся вторым, и читатель успевает прочитать название задачи, пока история ещё в пути.
Цена решения — код ответа. Он уходит с первым куском, поэтому notFound() внутри Activity уже не сделает 404: страница ответит 200 и просто покажет «не найдено» в теле.
Если по событиям нужно кликать — сворачивать, фильтровать, — Activity становится клиентским, а async в клиентском компоненте не бывает. Тогда промис не дожидаются в странице, а передают вниз пропсом: серверный компонент начинает работу, клиентский её ждёт через use(). Промис — одно из немногого, что через границу проходит; список того, что можно передавать, был в статье «Что можно передать через границу».
Оба экрана целиком — это пять файлов, и ни в одном нет ни useState, ни useEffect.
| Файл | Что в нём | Чего ждёт |
|---|---|---|
lib/tasks.ts | listTasks, getTask, listEvents | ничего не рисует — только отвечает |
app/board/page.tsx | доска: колонки и карточки | listTasks() |
app/board/[id]/page.tsx | карточка задачи и граница вокруг ленты | params и getTask(id) |
app/board/[id]/not-found.tsx | экран «такой задачи нет» | ничего: он готов заранее |
app/board/[id]/loading.tsx | заглушка на время перехода | показывается, пока ждёт страница |
Последняя строка — та самая обёртка из главы про App Router: loading.tsx подменяет содержимое сегмента, пока страница ждёт свои 30 мс, а Suspense внутри страницы отвечает за ленту. Два разных ожидания, две разные заглушки, и путать их не надо: одно про переход на адрес, другое про кусок разметки.
Всё это — чтение. Кнопки «добавить» на доске пока нет, статус карточки не меняется, форма ничего не отправляет: изменения приходят в следующей статье, вместе с действиями. А то, что данные при каждом заходе спрашиваются заново, — тема главы про кеш; пока это ровно то поведение, которое нужно, чтобы видеть свои же правки.
- Вопросы к данным живут в
lib/tasks.ts, а не в компонентах: те же функции понадобятся действиям из следующей статьи. - Обе страницы —
async-компоненты: сервер зовёт их как обычные функции, ждёт промис и превращает результат в разметку. paramsприходит промисом, и забытыйawaitловится компилятором, а не загадочнымundefinedв запросе.notFound()объявлен какnever— он даёт и честный 404, и сужениеTask | undefinedдоTaskв остальной разметке.- Границу
Suspenseставят вышеawaitмедленной части: ожидание переезжает в отдельный компонент, оболочка уходит первым куском. - Пока это только чтение: изменения, формы и действия — тема следующей статьи главы.
1/4В странице задачи написано: const task = await getTask(id); if (!task) notFound(); return <h1>{task.title}</h1>. Почему компилятор молчит на task.title, хотя getTask возвращает Task | undefined?
2/4Заголовок задачи готов через 30 мс, лента событий — через 2 секунды. В странице стоит const events = await listEvents(id), а разметка ленты обёрнута в <Suspense>. Когда читатель увидит заголовок?
3/4В app/board/[id]/page.tsx написано const task = await getTask(params.id). Что будет?
4/4Где в проекте уместно позвать notFound()?
Отпустить заголовок
Страница ждёт ленту событий, хотя показывать её сразу необязательно. Перепишите так, чтобы заголовок уходил в браузер, не дожидаясь ленты: ожидание должно оказаться под границей, а не над ней.
Ожидание переезжает в отдельный компонент, а на его место в странице встаёт граница с заглушкой. Страница теперь ждёт только задачу.
Обёртка вокруг ul прямо в странице не помогла бы: await остался бы выше границы, а граница действует только на то, что внутри неё.
404 вместо карточки-призрака
Страница показывает заглушку вместо отсутствующей задачи и отвечает кодом 200. Замените заглушку на notFound() — и обратите внимание, что после этого станет с вопросительными знаками в разметке.
Одна проверка вместо двух заглушек, и разметка становится прямой: task ниже вызова сужен до Task, поэтому ?. и ?? больше не нужны.
Рядом кладётся app/board/[id]/not-found.tsx с человеческим экраном и ссылкой на доску. Заворачивать вызов в try..catch нельзя: свой catch перехватит служебную ошибку раньше Next.
Три похода вместо одного
Доска зовёт базу по разу на колонку — и делает это последовательно, потому что каждый await ждёт предыдущего. Переделайте на один запрос за всеми задачами и раскладку по колонкам на месте.
Колонок на доске три, а вопрос у них один — «какие вообще есть задачи». Один запрос, дальше filter по уже полученному массиву.
Если запросы по колонкам всё же нужны — например, каждая колонка со своей постраничной выборкой, — их хотя бы не выстраивают в очередь: Promise.all отправит все три разом, и доска подождёт самый долгий, а не сумму.