Перейти к содержанию

Параметр адреса и подробная карточка

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

Оглавление серии