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

Удаление, отсутствующая запись и границы 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.

После самостоятельного запуска ожидается последовательность: каталог, подробная карточка, создание, подтверждённый переход и удаление с возвращением к списку. Неизвестный маршрут показывает отдельный экран, а отсутствующая запись — собственное сообщение. Никаких тестов, сборок, установок и измерений при подготовке курса не выполнялось. Следующий учебный проект можно строить на этой модели, но расширять её следует по конкретной задаче: сначала уточнить контракт, затем изменить сервер, клиент и проверяемые ожидания вместе.

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