Проект: план, маршруты и раскладки
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска задач из части II умела ровно то же самое — и жила в одном файле, в одном адресе и в localStorage. Теперь у каждой задачи будет свой адрес, данные приедут с сервера, а форму задачи опишет тип. Значит, до первой строки разметки нужно решить три вещи: какие данные, какие маршруты и где пройдёт граница между сервером и браузером.
Список умений почти не изменился с части II, но проверять их теперь придётся иначе: не «карточка появилась на странице», а «по адресу открылось то, что нужно». Адрес — новая часть договора, и он же главная причина переезда на Next.js: на задачу можно дать ссылку.
| Умеет | Как проверяем |
|---|---|
Показывает доску по адресу / | Три колонки, задачи разложены по статусам |
Открывает задачу по адресу /tasks/t1 | Ссылка с карточки ведёт на отдельную страницу, адрес можно скопировать и прислать |
| Отвечает «не найдено» на чужой id | /tasks/нет-такой показывает страницу 404, а не пустой экран |
| Добавляет задачу формой | После отправки карточка появляется в «Сделать» |
| Переносит карточку в соседнюю колонку | Кнопка на карточке меняет статус, доска обновляется |
| Помнит задачи между заходами | Список живёт на сервере, а не в браузере одного человека |
И сразу — чего в проекте не будет. Ни входа по паролю, ни настоящей базы: список задач будет лежать в памяти сервера. Ни перетаскивания мышью, ни анимаций. Этого достаточно, чтобы пройти серверные компоненты, действия и выкладку, и мало, чтобы утонуть в постороннем.
Разница с частью II глубже, чем «добавились адреса». Там страница приходила пустой, а список рисовал render() в браузере — из localStorage. Здесь первый же кадр приезжает с задачами внутри, потому что компонент доски выполняется на сервере: как это работает, разбирали статьи «Серверный компонент: код, который не уехал в браузер» и «Данные прямо в компоненте: await вместо useEffect».
Многофайловый проект песочница не запускает: в ней нет ни Next, ни файловой системы, ни сервера. Всё, что можно проверить одним файлом, ниже запускается по-настоящему; остальное — настоящие файлы проекта: компилятор их проверит, а выполнять будет нечего. Там, где так, это сказано прямо, а не замаскировано.
Первое решение вообще не про React. Пока форма задачи не описана в одном месте, каждый файл придумывает её заново: где-то status — строка, где-то число, а где-то поле называется state. В части III это лечилось одним приёмом — тип как договор; здесь договор нужен ещё и потому, что данные поедут через границу сервер — браузер.
Три решения в этом файле стоит назвать вслух. Status — объединение литералов, а не string: список колонок конечен, и компилятор об этом знает. NewTask получен из Task через Omit, а не выписан руками — иначе два типа однажды разъедутся. COLUMNS — это Record<Status, string>, то есть обещание, что заголовок есть у каждого статуса.
Последнее обещание и проверим. В доску добавляют колонку «На проверке» — правится ровно один тип. Прежде чем нажать Run, решите: на какую строку покажет компилятор?
Он показывает на объявление COLUMNS, а не на место, где заголовок понадобился. Это и есть польза от Record: правка типа сама приводит к списку мест, которые обязаны догнать. Вторая строка при этом всё равно печатается — типы до выполнения не доживают, а tsc по умолчанию собирает код даже с ошибками.
id задачи — строка, хотя в базе он однажды может стать числом. Причина простая: в адресе он всё равно строка, и лишний Number(params.id) — лишний повод ошибиться.
enum напрашивается, но он приносит с собой значение — объект, который надо создать в рантайме и протащить через границу. Объединение литералов не существует после сборки вовсе: по сети едет строка "todo", а в JSON она попадает без единого преобразования. Разбор обоих вариантов — в статье «enum и его замена объектом as const» из части III.
Адрес в App Router — это папка, и другого источника правды у него нет: правило разбирала статья «App Router: адрес — это папка». Значит, спроектировать маршруты — буквально нарисовать дерево папок. Вот наше.
| Файл | Адрес | Что показывает |
|---|---|---|
app/layout.tsx | — | Шапка и <html>: обёртка всего сайта |
app/page.tsx | / | Доска: три колонки с карточками |
app/tasks/layout.tsx | — | Узкая колонка и ссылка «к доске» |
app/tasks/[id]/page.tsx | /tasks/t1 | Одна задача целиком |
app/tasks/[id]/edit/page.tsx | /tasks/t1/edit | Форма правки этой задачи |
app/tasks/[id]/not-found.tsx | — | Экран «задача не найдена» |
Соберём адреса из этого списка тем же способом, каким это делает Next при старте, — и заодно увидим, какие файлы адресов не дают.
Семь файлов — три адреса. Ни layout.tsx, ни not-found.tsx адреса не создают: зарезервированное имя меняет поведение папки, но сегментом не становится. А всё, что лежит вне app, маршрутизатор не смотрит вовсе: компоненты живут в src и попадают на страницу только импортом.
Теперь обратный ход: по адресу найти маршрут. Квадратные скобки — дырка под любое значение, поэтому сравнивать нужно посегментно. Перед запуском ответьте на последний вопрос примера: что откроется по /tasks?
По /tasks — 404, хотя папка app/tasks в проекте есть и раскладка в ней тоже. Папка без page.tsx адреса не даёт, и это не недосмотр плана: отдельного экрана «все задачи списком» у нас нет, потому что доска и есть этот список. Если бы он понадобился, лечилось бы одним файлом.
Компоненты в дереве маршрутов не живут. В app лежат только страницы и раскладки, всё остальное — рядом, в src, папками по имени модуля: src/ui/task-card/task-card.tsx, рядом с ним task-card.module.css и task-card.types.ts. Стили и сборка — тема последней статьи главы, но место для них выбирается сейчас.
Форму правки можно было бы открывать по /tasks/t1?mode=edit. Мы делаем папку edit — и получаем отдельный файл, отдельную раскладку при желании и адрес, который видно в дереве. Строка запроса в дереве папок не отражается вовсе: об этом была статья «Динамические сегменты и асинхронные params».
Раскладок в проекте две, и уровень каждой задан папкой — так же, как адрес. Корневая обязательна и единственная, где встречаются <html> и <body>.
В настоящем файле перед функцией стоит export default: Next импортирует страницы и раскладки сам, и договариваться об имени ему не с кем. В примерах это слово опущено — песочница выполняет один файл без модулей.
Этот пример ничего не печатает и ничего не рисует: <html> внутрь страницы песочницы не смонтировать. Компилятор его при этом проверил — и на том спасибо; подделывать запуск мы не станем.
Вторая раскладка — на папке tasks. Она обнимает и страницу задачи, и форму правки: у обеих узкая колонка и одинаковый путь назад. Вот эта пара уже запускается.
Ссылка «к доске» лежит именно в раскладке, а не в двух страницах — и дело не в экономии четырёх строк. При переходе с /tasks/t1 на /tasks/t1/edit общая часть цепочки не пересоздаётся: раскладка остаётся смонтированной, меняется только хвост. Что переживает переход, а что сбрасывается, разбирала статья «Вложенные раскладки, группы и шаблоны».
Осталась сама доска. Три колонки — не три копии разметки, а цикл по списку статусов: заголовки берутся из COLUMNS, порядок — из отдельного массива.
В кадре — каркас будущей доски: колонки, счётчики и ссылки на страницы задач. Данные пока вписаны прямо в компонент, и это честный шаг плана: разметку видно, а откуда возьмётся массив tasks, решает следующая статья главы.
Пропсы Column описаны типом не ради красоты. Вот что происходит, если в разметке появится колонка, которой нет в плане.
Компилятор говорит об этом до запуска, а браузер потом всё равно рисует пустой «Архив» — ошибка типов выполнение не отменяет. Разница в том, где вы об этом узнаете: в редакторе через секунду или на живой доске через неделю.
Третье решение плана — самое дорогое при ошибке. Директива 'use client' красит не файл, а всё поддерево его импортов: почему так, разбирала статья «Граница 'use client' и почему она заразна». Поэтому спрашивать надо не «какой компонент интерактивный», а «что уедет в браузер вместе с ним».
Пройдёмся по доске. Кнопка «дальше» на карточке меняет данные — значит, это форма с серверным действием, и состояния в браузере ей не нужно. Форма добавления — тоже. А вот живой фильтр по названию, который прячет карточки прямо во время набора, без состояния не работает: он и станет островком.
Посчитаем цену обоих решений. Перед запуском прикиньте: сколько файлов уедет в браузер во втором случае?
Один файл против всех четырёх — и это на игрушечном дереве из четырёх узлов. В настоящем проекте «поставлю директиву повыше, чтобы не мучиться» отправляет в браузер половину приложения вместе с разметкой, которая могла остаться на сервере. Отсюда правило плана: директива ставится на самый мелкий лист, какой получится, а серверная разметка попадает внутрь островка через children — приём из статьи «Что можно передать через границу».
| Файл | Где выполняется | Почему |
|---|---|---|
app/page.tsx | сервер | Только читает список и раскладывает по колонкам |
src/ui/board-column | сервер | Разметка без единого обработчика |
src/ui/task-card | сервер | Ссылка и заголовок — этого хватает |
src/ui/task-filter | браузер | Держит строку поиска в состоянии |
src/ui/submit-button | браузер | Показывает «Сохраняю…», а это useFormStatus |
В песочнице всё выполняется в браузере — сервера в ней нет. Поэтому граница здесь только считается, а не наблюдается: примеры про неё показывают решение, а не его последствия.
План есть — осталось решить, в каком порядке он превращается в код. Порядок тот же, что был в части II, и по той же причине: каждый следующий шаг проверяется по уже работающему предыдущему, а не по обещанию.
| Шаг | Что появляется | Чем проверяем |
|---|---|---|
| Дерево и раскладки | Папки, две раскладки, каркас доски с выдуманными данными | Открываются все три адреса, шапка на месте, /tasks честно даёт 404 |
| Данные на сервере | Список и страница задачи читают данные прямо в компоненте | Первый кадр приходит с задачами; во вкладке «Сеть» нет запроса за списком |
| Изменения через действия | Форма добавления и кнопка переезда | После отправки доска показывает новое состояние |
| Стили и выкладка | CSS-модули, сборка, боевой адрес | Прод собирается и открывается по своему домену |
Первый шаг — эта статья: дерево нарисовано, типы описаны, каркас доски рисуется. Начинать с данных было бы соблазнительно, но тогда отлаживаются сразу две несуществующие вещи, и непонятно, что именно сломалось — запрос или разметка.
И последнее решение плана, которое стоит проговорить: пока данных нет, они вписаны прямо в компонент — массивом, как в примерах выше. Это не «времянка, которую потом заменим», а способ проверить разметку отдельно от источника. Заменить массив на чтение с сервера — работа следующей статьи, и она займёт несколько строк.
- Границы проекта заданы заранее: доска по
/, задача по/tasks/[id], форма правки и честная 404. Ни входа по паролю, ни базы, ни перетаскивания мышью. - Форма данных описана один раз:
Status— объединение литералов,NewTaskвыведен изTaskчерезOmit, аRecord<Status, string>заставляет компилятор следить за полнотой списка колонок. - Маршруты — это дерево папок: адрес даёт только
page.tsx, а папка без него (нашapp/tasks) честно отвечает 404. - Раскладок две: корневая с
<html>и шапкой иapp/tasks/layout.tsxс узкой колонкой и путём назад — в ней лежит всё, что должно пережить переход между страницами задачи. - Граница
'use client'проведена по самому мелкому листу: интерактивен только фильтр, остальное остаётся на сервере. Директива повыше утащила бы в браузер всё поддерево импортов. - Порядок работы: каркас и маршруты, потом данные с сервера, потом изменения через действия, потом стили и выкладка — каждый шаг проверяется по работающему предыдущему.
1/4В проекте есть app/tasks/layout.tsx и app/tasks/[id]/page.tsx, но файла app/tasks/page.tsx нет. Что откроется по адресу /tasks?
2/4В тип Status добавили значение "review". Где компилятор укажет на проблему первым?
3/4Директиву 'use client' поставили первой строкой app/page.tsx — страницы доски. Что после этого уедет в браузер?
4/4Почему ссылка «к доске» живёт в app/tasks/layout.tsx, а не в обеих страницах задачи?
Переезд карточки, который проверяет компилятор
Ту же функцию вы писали в части II — теперь у неё есть типы. Приведите move в порядок: она должна возвращать новый массив с изменённым статусом одной задачи, не трогая исходный, и не принимать статус, которого нет в Status.
Правки две, и обе короткие. В сигнатуре status: string заменяется на status: Status — иначе тип задачи не защищён вообще: в поле status попадёт что угодно. В теле — map, как в части II: он собирает новый массив, подменяя нужную задачу её копией.
Проверки печатают done, todo и false, а последняя строка перестаёт компилироваться: компилятор сообщает, что аргумент типа "archived" нельзя назначить параметру типа Status. Выполниться она, впрочем, всё равно выполнится — типы исчезают при сборке, ошибку они ловят до неё.
Достроить дерево до плана
В проекте должны появиться страница задачи, форма её правки и архив по адресу /archive. Дополните список files так, чтобы все проверки внизу сошлись, — включая последнюю: /tasks обязан остаться четырёхсоткой.
Нужны три файла page.tsx — и ни одного лишнего. Раскладку app/tasks/layout.tsx добавить можно: на адреса она не влияет, что видно по фильтру.
Соблазн добавить app/tasks/page.tsx «для порядка» ломает последнюю проверку: адрес /tasks появится, и открывать по нему будет нечего. Папка без страницы — нормальное состояние дерева, а не пробел в нём.
Подвинуть границу клиента
Сейчас директива стоит на странице доски, и в браузер уезжает всё дерево. Переставьте её так, чтобы клиентским остался ровно один файл — тот, которому нужно состояние. Считать руками не нужно: ответ печатают две последние строки.
Директива снимается со страницы и ставится на фильтр — единственный узел, которому нужно состояние в браузере:
Печатается 1 и task-filter. Обратите внимание, что app/page.tsx при этом продолжает импортировать клиентский файл — и остаётся серверной: граница расходится вниз по импортам, а не вверх. Серверный родитель имеет полное право отрисовать клиентского ребёнка.