Параметр адреса и подробная карточка
В списке каждая запись уже имеет стабильный идентификатор. Теперь он станет частью адреса: /notes/html-start откроет подробную карточку HTML, а /notes/css-layout — карточку сетки CSS. Приложение прочитает параметр пути и запросит отдельный объект через знакомый useResource.
Такой переход соединяет три независимых договора. Router выделяет параметр из URL, наш клиент проверяет его допустимый вид, сервер ищет запись с этим идентификатором. Если один из договоров нарушен, нельзя просто показать первую карточку списка: адрес начнёт обозначать не то содержимое, которое обещает.
Параметр маршрута не является готовыми данными
В App добавляем Route для /notes/:noteId. Двоеточие обозначает динамический сегмент. Имя noteId затем используется в useParams. Строка html-start не превращается в объект записи и не загружается автоматически: маршрутизатор только предоставляет часть адреса.
Ниже обозначенный фрагмент src/App.jsx: импорт добавляется к существующим импортам, а Route — внутрь уже существующего Routes. Полный файл находится в состоянии lesson-13/after.
import NoteDetail from './pages/NoteDetail.jsx';
// Внутри <Routes> рядом с маршрутами / и /new:
<Route path="/notes/:noteId" element={<NoteDetail />} />
Не добавляем новый BrowserRouter внутри NoteDetail. Общий контекст уже находится над App. Правила динамических сегментов описаны в документации React Router, а допустимый формат идентификатора является решением нашей модели.
Превращаем заголовок в ссылку
LessonList должен вести к подробной карточке через Link. Подписью служит название, а адрес строится из id. Это сохраняет доступный пользователю текст и не привязывает URL к изменяемому заголовку.
Полный src/components/LessonList.jsx:
import {Link} from 'react-router';
export default function LessonList({title, notes, onHide}) {
return <section><h2>{title}</h2>{notes.length === 0 ? <p>В подборке пока нет карточек.</p> :
<ul className="cards">{notes.map(note => <li key={note.id} className="card"><article>
<h3><Link to={`/notes/${note.id}`}>{note.title}</Link></h3><p>{note.topic} · {note.minutes} минут</p><p>{note.body}</p>
{onHide && <button type="button" onClick={() => onHide(note.id)}>Убрать из подборки</button>}
</article></li>)}</ul>}</section>;
}
Кнопка скрытия остаётся отдельным действием. Она не вложена в Link, поэтому нажатие кнопки не должно одновременно открывать карточку. Такое разделение проще, чем общий кликабельный контейнер с остановкой всплытия событий внутри каждой кнопки.
Идентификаторы наших записей разрешают только строчные латинские буквы, цифры и дефис. Поэтому они помещаются в сегмент без пробелов и слешей. Для произвольных пользовательских строк потребовалось бы отдельно продумать кодирование, нормализацию и стабильность адресов. Заголовок урока для этого не подходит.
Компонент подробной карточки
Создадим src/pages/NoteDetail.jsx целиком:
import {Link, useParams} from 'react-router';
import {useResource} from '../hooks/useResource.js';
import {parseNote} from '../lib/models.js';
export default function NoteDetail() {
const {noteId} = useParams();
const valid = /^[a-z0-9-]{1,64}$/.test(noteId ?? '');
const {status, data, error, reload} = useResource(valid ? `/api/notes/${noteId}` : null, parseNote);
if (status === 'loading') return <p role="status">Загружаем карточку…</p>;
if (status === 'error') return <section><h1>{error?.status === 404 ? 'Карточка не найдена' : 'Не удалось открыть карточку'}</h1>
<p>Проверьте адрес или вернитесь в каталог.</p><Link to="/">Каталог</Link>{valid && error?.status !== 404 && <button type="button" onClick={reload}>Повторить чтение</button>}</section>;
return <article><h1>{data.title}</h1><p>{data.topic} · {data.minutes} минут</p><p className="note-body">{data.body}</p><Link to="/">В каталог</Link></article>;
}
UseParams возвращает строковое значение параметра. Мы не преобразуем его через Number: html-start является допустимым идентификатором, хотя числом не является. Регулярное выражение проверяет только формат и длину. Оно не доказывает существование записи и не даёт пользователю полномочия её изменять.
Хук вызывается в каждом рендере на одном месте. Для недопустимого параметра передаём null, и useResource формирует состояние ошибки без запроса по произвольному адресу. Неправильный вариант — вызвать хук только внутри if(valid), а потом иногда его пропускать. Это нарушит последовательность вызовов React-хуков.
ParseNote проверяет один объект, а не массив. Поля выбираются явно: id, title, topic, minutes и body. Ответ нашего fixture по отдельному endpoint возвращает найденную запись; клиент дополнительно проверяет её структуру. Такая проверка не является криптографическим подтверждением происхождения и не заменяет контракт сервера.
| Адрес | Что проверяется локально | Ожидаемый запрос и результат |
|---|---|---|
| /notes/html-start | Допустимый формат | GET, карточка HTML |
| /notes/missing-note | Допустимый формат | GET, серверный 404 |
| Сегмент с неподдерживаемыми символами | Формат отклонён | Запрос не отправляется |
| /something-else | Другой маршрут | Не задача NoteDetail |
Ошибка записи и ошибка маршрута
Ответ API с 404 означает, что подходящий маршрут приложения существует, но запись на сервере не найдена. Поэтому NoteDetail показывает «Карточка не найдена» и предлагает каталог. Повторять чтение отсутствующей записи автоматически не нужно: сохранённый адрес мог устареть или быть ошибочным.
Другие ошибки получают сообщение «Не удалось открыть карточку» и кнопку повторного чтения при допустимом параметре. Если сервер временно недоступен, пользователь может восстановить соединение и попробовать GET снова. Такая кнопка не создаёт запись и не повторяет серверное изменение.
Совсем неизвестный экран, например /settings, — другая ситуация. Его обработку добавим wildcard-маршрутом в последнем уроке. Пока важно не смешивать отсутствие маршрута с отсутствием данных: исправления находятся в разных частях проекта.
Быстрая смена адресов
Представим, что пользователь переходит с HTML на CSS, пока первый запрос ещё ожидается. UseResource отменяет прежнюю синхронизацию и не принимает её поздний результат. Проверка state.url дополнительно не позволяет показывать HTML под уже изменившимся адресом CSS до настройки нового эффекта.
Без такой связи можно получить очень убедительную ошибку: строка адреса показывает css-layout, а заголовок — «Семантика HTML». Оба объекта по отдельности валидны, но относятся к разным запросам. Поэтому проверка полей и проверка принадлежности состояния текущему ресурсу решают разные задачи.
Мы не добавляем искусственные задержки и не заявляем, что гонка воспроизведена. Это разбор возможной последовательности завершений. Исходник должен быть корректен независимо от того, какой из двух GET завершится первым.
Переименование «Семантика HTML» в «Основы семантики» не потребует менять /notes/html-start. Связь с id отделяет адрес от редакторского текста. Если же заменить сам идентификатор, старый адрес перестанет находить запись, поскольку таблицы перенаправлений у fixture нет. Поэтому даже в маленьком примере полезно сохранять id после создания, а изменения заголовка рассматривать как другую операцию модели.
Текст карточки не становится HTML
Body выводится в обычном JSX-выражении. CSS-класс note-body сохраняет переносы строк через white-space, но не интерпретирует содержимое как разметку. Если в учебной заметке появится строка с тегом, она остаётся текстом. Для Markdown потребовался бы отдельный формат хранения, преобразователь и правила обработки результата.
Не используем dangerouslySetInnerHTML, чтобы случайно оживить произвольный ввод будущей формы. Назначение этой SPA — учебные текстовые заметки о вебе. Оно отличается от редакционного Markdown-процесса самого ProfessorWeb, где материалы проходят другой путь генерации статических страниц.
После самостоятельного запуска ссылка «Семантика HTML» должна открыть соответствующий адрес и подробный текст. Переход в каталог возвращает список, а неизвестный допустимый id — сообщение об отсутствии записи. При подготовке уроков выполнение не проводилось. Теперь адрес и данные связаны; следующим шагом создадим управляемую форму без отправки, чтобы отдельно разобрать ввод и проверку.