Проект: план, маршруты и раскладки

проект≈ 12 минут

Здесь будет встроен видео-разбор темы (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 это лечилось одним приёмом — тип как договор; здесь договор нужен ещё и потому, что данные поедут через границу сервер — браузер.

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

Три решения в этом файле стоит назвать вслух. Status — объединение литералов, а не string: список колонок конечен, и компилятор об этом знает. NewTask получен из Task через Omit, а не выписан руками — иначе два типа однажды разъедутся. COLUMNS — это Record<Status, string>, то есть обещание, что заголовок есть у каждого статуса.

Последнее обещание и проверим. В доску добавляют колонку «На проверке» — правится ровно один тип. Прежде чем нажать Run, решите: на какую строку покажет компилятор?

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

Он показывает на объявление 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 при старте, — и заодно увидим, какие файлы адресов не дают.

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

Семь файлов — три адреса. Ни layout.tsx, ни not-found.tsx адреса не создают: зарезервированное имя меняет поведение папки, но сегментом не становится. А всё, что лежит вне app, маршрутизатор не смотрит вовсе: компоненты живут в src и попадают на страницу только импортом.

Теперь обратный ход: по адресу найти маршрут. Квадратные скобки — дырка под любое значение, поэтому сравнивать нужно посегментно. Перед запуском ответьте на последний вопрос примера: что откроется по /tasks?

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

По /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>.

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

В настоящем файле перед функцией стоит export default: Next импортирует страницы и раскладки сам, и договариваться об имени ему не с кем. В примерах это слово опущено — песочница выполняет один файл без модулей.

Этот пример ничего не печатает и ничего не рисует: <html> внутрь страницы песочницы не смонтировать. Компилятор его при этом проверил — и на том спасибо; подделывать запуск мы не станем.

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

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

Ссылка «к доске» лежит именно в раскладке, а не в двух страницах — и дело не в экономии четырёх строк. При переходе с /tasks/t1 на /tasks/t1/edit общая часть цепочки не пересоздаётся: раскладка остаётся смонтированной, меняется только хвост. Что переживает переход, а что сбрасывается, разбирала статья «Вложенные раскладки, группы и шаблоны».

Осталась сама доска. Три колонки — не три копии разметки, а цикл по списку статусов: заголовки берутся из COLUMNS, порядок — из отдельного массива.

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

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

Пропсы Column описаны типом не ради красоты. Вот что происходит, если в разметке появится колонка, которой нет в плане.

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

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

Третье решение плана — самое дорогое при ошибке. Директива 'use client' красит не файл, а всё поддерево его импортов: почему так, разбирала статья «Граница 'use client' и почему она заразна». Поэтому спрашивать надо не «какой компонент интерактивный», а «что уедет в браузер вместе с ним».

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

Посчитаем цену обоих решений. Перед запуском прикиньте: сколько файлов уедет в браузер во втором случае?

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

Один файл против всех четырёх — и это на игрушечном дереве из четырёх узлов. В настоящем проекте «поставлю директиву повыше, чтобы не мучиться» отправляет в браузер половину приложения вместе с разметкой, которая могла остаться на сервере. Отсюда правило плана: директива ставится на самый мелкий лист, какой получится, а серверная разметка попадает внутрь островка через 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.

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

Правки две, и обе короткие. В сигнатуре status: string заменяется на status: Status — иначе тип задачи не защищён вообще: в поле status попадёт что угодно. В теле — map, как в части II: он собирает новый массив, подменяя нужную задачу её копией.

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

Проверки печатают done, todo и false, а последняя строка перестаёт компилироваться: компилятор сообщает, что аргумент типа "archived" нельзя назначить параметру типа Status. Выполниться она, впрочем, всё равно выполнится — типы исчезают при сборке, ошибку они ловят до неё.

Достроить дерево до плана

В проекте должны появиться страница задачи, форма её правки и архив по адресу /archive. Дополните список files так, чтобы все проверки внизу сошлись, — включая последнюю: /tasks обязан остаться четырёхсоткой.

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

Нужны три файла page.tsx — и ни одного лишнего. Раскладку app/tasks/layout.tsx добавить можно: на адреса она не влияет, что видно по фильтру.

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

Соблазн добавить app/tasks/page.tsx «для порядка» ломает последнюю проверку: адрес /tasks появится, и открывать по нему будет нечего. Папка без страницы — нормальное состояние дерева, а не пробел в нём.

Подвинуть границу клиента

Сейчас директива стоит на странице доски, и в браузер уезжает всё дерево. Переставьте её так, чтобы клиентским остался ровно один файл — тот, которому нужно состояние. Считать руками не нужно: ответ печатают две последние строки.

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

Директива снимается со страницы и ставится на фильтр — единственный узел, которому нужно состояние в браузере:

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

Печатается 1 и task-filter. Обратите внимание, что app/page.tsx при этом продолжает импортировать клиентский файл — и остаётся серверной: граница расходится вниз по импортам, а не вверх. Серверный родитель имеет полное право отрисовать клиентского ребёнка.