Удаление, отсутствующая запись и границы SPA
Последний шаг завершает учебный каталог: подробная карточка получает удаление, а неизвестный путь — понятный экран с возвращением к списку. Здесь особенно важно различить удаление в памяти API, локальное скрытие из подборки и отсутствие маршрута. Эти ситуации выглядят похожими только при слишком общих сообщениях.
Итоговый проект остаётся локальным учебным CRUD-примером. Он показывает компоненты, состояние, загрузку, маршруты и работу с формой, но не заменяет статический ProfessorWeb. Отдельно обозначим, что ещё потребуется для настоящего приложения и почему клиентская страница «не найдено» не гарантирует правильный HTTP-статус для поискового робота.
Удаление принадлежит подробной карточке
Создадим отдельный DeleteNote, который получает уже проверенную запись. Перед запросом используется окно подтверждения с её названием. Отмена подтверждения заканчивает обработчик без изменения состояния и без DELETE. Это простая локальная защита от случайного действия, а не проверка полномочий пользователя.
Полный src/components/DeleteNote.jsx:
import {useEffect, useRef, useState} from 'react';
import {useNavigate} from 'react-router';
import {requestJson, HttpError} from '../lib/api.js';
export default function DeleteNote({note}) {
const navigate = useNavigate();
const [status, setStatus] = useState('idle');
const [message, setMessage] = useState('');
const busy = useRef(false);
const active = useRef(true);
const request = useRef(null);
useEffect(() => {active.current = true; return () => {active.current = false; request.current?.abort();};}, []);
async function remove() {
if (busy.current || !window.confirm(`Удалить учебную карточку «${note.title}»?`)) return;
busy.current = true; setStatus('sending'); setMessage('');
const controller = new AbortController(); request.current = controller;
const timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await requestJson(`/api/notes/${note.id}`, {method: 'DELETE', signal: controller.signal});
if (response.status !== 204) throw new Error('Неожиданное подтверждение удаления');
if (active.current) navigate('/', {replace: true});
} catch (cause) {
if (!active.current) return;
const rejected = cause instanceof HttpError && cause.status >= 400 && cause.status < 500;
setStatus(rejected ? 'idle' : 'unknown');
setMessage(cause?.status === 404 ? 'Карточка уже отсутствует. Прочитайте каталог заново.' : rejected ? 'Сервер отклонил удаление.' : 'Итог удаления не подтверждён. Проверьте каталог, не повторяя действие вслепую.');
if (rejected) busy.current = false;
} finally {clearTimeout(timer); request.current = null;}
}
return <section><button type="button" onClick={remove} disabled={status !== 'idle'}>{status === 'sending' ? 'Удаляем…' : status === 'unknown' ? 'Нужна сверка удаления' : 'Удалить карточку'}</button>{message && <p role="alert">{message}</p>}</section>;
}
Как и в POST, busy ref ограничивает повторный вызов текущего обработчика. Sending отключает кнопку, таймер ограничивает ожидание, active запрещает поздний переход после размонтирования. DELETE начинается только по нажатию пользователя, а не из эффекта, который автоматически выполняется при открытии карточки.
На успешном пути fixture отвечает 204 без JSON-тела. RequestJson специально обрабатывает такой статус до вызова response.json. Попытка разобрать пустое тело как JSON превратила бы выполненное удаление в ошибку клиента. Код должен учитывать договор ответа, а не всегда ожидать объект.
Подключаем действие без смешения идентификаторов
В NoteDetail импортируем DeleteNote и помещаем его рядом с текстом карточки. Полный файл последнего этапа:
import {Link, useParams} from 'react-router';
import {useResource} from '../hooks/useResource.js';
import {parseNote} from '../lib/models.js';
import DeleteNote from '../components/DeleteNote.jsx';
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><DeleteNote key={data.id} note={data} /><Link to="/">В каталог</Link></article>;
}
Key={data.id} связывает локальное состояние компонента удаления с конкретной записью. Если подробный экран получит другой объект, новый экземпляр не должен наследовать сообщение unknown от прежней карточки. Это другое применение стабильного ключа, помимо списка li: мы явно обозначаем границу состояния действия.
Кнопка работает с note.id, полученным из подтверждённых данных API. У клиента нет самостоятельного списка полномочий. Fixture не содержит пользователей и поэтому не моделирует владельца карточки. Для многопользовательского сервиса сервер обязан проверять доступ к каждой операции; правильный id и окно confirm таких полномочий не предоставляют.
| Результат | Что означает в нашем API | Что делать интерфейсу |
|---|---|---|
| 204 | Удаление подтверждено | Перейти в каталог |
| 404 | Запись уже отсутствует | Предложить прочитать каталог |
| Другой 4xx | Запрос отклонён | Показать отказ |
| 5xx или потерянный ответ | Итог не подтверждён | unknown, без слепого повтора |
404 не является подтверждением, что именно текущая операция удалила запись. Например, она могла исчезнуть ранее. Сообщение поэтому говорит об отсутствии, а не поздравляет с выполненным удалением. Повторное чтение каталога позволяет увидеть текущее состояние, хотя не восстанавливает историю произошедшего.
Неизвестный итог удаления
После таймаута сервер мог уже убрать объект. Автоматический повтор привёл бы к 404, но он не объяснил бы, какое из двух действий изменило массив. Даже для удаления полезно сохранять точность результата и не считать любую ошибку доказательством сохранности данных.
В unknown кнопка остаётся заблокированной в текущем экземпляре. Читатель может перейти в каталог и выполнить GET. Если карточка отсутствует, подробный адрес даст серверный 404. Если присутствует, решение о новом действии принимается после сверки. Полноценного журнала операций, идемпотентных ключей и постоянного хранилища у fixture нет.
Локальное «Убрать из подборки» из ранних уроков совсем не вызывает DELETE. Оно добавляет id в hiddenIds только у Home. Восстановление подборки возвращает такие карточки, но не может вернуть запись, действительно удалённую из серверной памяти. Для возврата исходного учебного набора можно перезапустить API, что является свойством fixture, а не пользовательской функцией восстановления.
Неизвестный путь получает отдельный экран
Создадим src/pages/NotFound.jsx:
import {Link} from 'react-router';
export default function NotFound() {
return <section><h1>Такого экрана нет</h1><p>Проверьте адрес или выберите карточку в каталоге.</p><Link to="/">Открыть каталог</Link></section>;
}
Затем добавим wildcard в общий Routes. Полный финальный App:
import {Routes, Route} from 'react-router';
import Navbar from './components/Navbar.jsx';
import Home from './pages/Home.jsx';
import CreateNote from './pages/CreateNote.jsx';
import NoteDetail from './pages/NoteDetail.jsx';
import NotFound from './pages/NotFound.jsx';
export default function App() {
return <><Navbar /><main><Routes>
<Route path="/" element={<Home />} />
<Route path="/new" element={<CreateNote />} />
<Route path="/notes/:noteId" element={<NoteDetail />} />
<Route path="*" element={<NotFound />} />
</Routes></main></>;
}
Путь /nothing соответствует NotFound, потому что не совпал с другими маршрутами. Путь /notes/missing-note соответствует NoteDetail и получает отсутствие записи от API. Эти экраны различают ошибку структуры URL и отсутствие ресурса по правильному шаблону.
Wildcard работает внутри загруженного приложения. Он не меняет уже полученный HTTP-статус HTML-документа. Если сервер вернул index.html с 200 на любой адрес, клиентский заголовок «Такого экрана нет» не превратит ответ в настоящий 404. Это надо решать на уровне сервера или серверного рендеринга.
Границы SPA при размещении
BrowserRouter требует согласованной выдачи HTML для прямых клиентских маршрутов. При этом /api должен остаться API, неизвестные ресурсы — получать корректный ответ, а существующие статические и старые страницы — сохранять собственные адреса. Простое правило «всё переписываем на index.html» нарушает эти границы.
Наш Vite proxy действует в разработке. В итоговом исходнике нет production-конфигурации хостинга, SSR, пререндеринга и генератора sitemap для заметок. Мы не обещаем поисковую полноту клиентской SPA без отдельного решения. Публичная библиотека ProfessorWeb продолжает использовать статические статьи и сохранённые URL; учебное приложение помогает изучить React, а не мигрирует библиотеку в браузерный CRUD.
Для реального продукта потребуется постоянное хранилище, авторизация, проверка владельца, политика ошибок, защита мутаций и восстановление неопределённых операций. Выбор этих механизмов зависит от задач. Удобная форма и работающий локальный DELETE не являются свидетельством готовности всего сервиса.
Как читать конечный проект
В архиве серии final содержит результат шестнадцатого этапа. Состояния lesson-01/after — lesson-16/after позволяют проследить изменения без догадки, какие импорты были добавлены между уроками. STATE фиксирует происхождение и ожидаемые результаты, а выполнение отмечено not_run.
После самостоятельного запуска ожидается последовательность: каталог, подробная карточка, создание, подтверждённый переход и удаление с возвращением к списку. Неизвестный маршрут показывает отдельный экран, а отсутствующая запись — собственное сообщение. Никаких тестов, сборок, установок и измерений при подготовке курса не выполнялось. Следующий учебный проект можно строить на этой модели, но расширять её следует по конкретной задаче: сначала уточнить контракт, затем изменить сервер, клиент и проверяемые ожидания вместе.