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

Собственный хук и отмена запросов

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

Собственный хук здесь является обычной функцией, использующей React-хуки. Он не создаёт общий серверный кеш и не превращает два запроса в один. Каждый вызов получает отдельное состояние и отдельный жизненный цикл. Повторно используется алгоритм, а не автоматически разделяемые данные.

Вход и выход useResource

Хук получает url и parse. Адрес определяет ресурс, а функция parse проверяет модель результата. Для списка это parseNotes, для отдельной карточки позже будет parseNote. Такая граница позволяет оставить HTTP-проверки в requestJson, модель — в отдельном модуле, а состояние React — внутри хука.

Полный src/hooks/useResource.js:

import {useEffect, useState} from 'react';
import {requestJson} from '../lib/api.js';
export function useResource(url, parse) {
  const [state, setState] = useState({url: null, status: 'loading', data: null, error: null});
  const [attempt, setAttempt] = useState(0);
  useEffect(() => {
    let active = true;
    const controller = new AbortController();
    if (url === null) {setState({url, status: 'error', data: null, error: new Error('Недопустимый адрес карточки')}); return () => {active = false; controller.abort();};}
    setState({url, status: 'loading', data: null, error: null});
    async function load() {
      try {
        const response = await requestJson(url, {signal: controller.signal});
        const data = parse(response.data);
        if (active) setState({url, status: 'success', data, error: null});
      } catch (error) {
        if (active && !controller.signal.aborted) setState({url, status: 'error', data: null, error});
      }
    }
    load();
    return () => {active = false; controller.abort();};
  }, [url, parse, attempt]);
  const current = state.url === url ? state : {url, status: 'loading', data: null, error: null};
  return {...current, reload: () => setAttempt(value => value + 1)};
}

Возвращаемый объект содержит status, data, error и reload. Компонент не обязан знать, как называется счётчик попыток или где создан AbortController. Но он обязан различать loading, error и success. Удобный интерфейс не отменяет смысл состояний, иначе потребитель снова начнёт вызывать map у null.

Имена функций-хуков начинаются с use. Этот признак помогает соблюдать правила вызова хуков: они вызываются на верхнем уровне компонента или другого хука, без условий и циклов. Если адрес неправильный, мы передаём null и получаем состояние ошибки, а не пропускаем вызов useResource. Такая ветка пригодится при проверке параметра маршрута.

Зачем хранится адрес полученного состояния

Представим переход с /api/notes/html-start на /api/notes/css-layout. Новый рендер уже получил новый url, но эффект ещё не успел записать loading. Без дополнительной проверки компонент мог бы на один кадр показать старую карточку под новым адресом. В state поэтому хранится url, к которому относятся данные.

Перед возвратом хук сравнивает state.url с текущим url. При несовпадении он возвращает loading без прежнего data. Это защита интерфейса между рендером и настройкой нового эффекта. Она дополняет флаг active, который защищает от позднего асинхронного результата, а не заменяет его.

Момент Адрес запроса Адрес сохранённых данных Возвращаемое состояние
Первая загрузка Список null loading
Успешное чтение Список Тот же список success
Новый адрес Карточка CSS Карточка HTML loading, без HTML
Ответ нового адреса Карточка CSS Карточка CSS success

Хук не проверяет значение route в DOM и не угадывает, чего ждёт пользователь. Связь определяется явным url. Если два разных адреса на сервере означают один ресурс, это отдельная серверная договорённость, которой наш простой fixture не содержит.

Очистка принадлежит одному запуску

В каждом эффекте создаются active и controller. После смены зависимостей React сначала завершает прежнюю синхронизацию. Очистка запрещает ей менять состояние и просит fetch отменить чтение. Даже если завершение старой операции попало в очередь позднее, active уже не позволит принять её данные.

Не стоит считать abort доказательством отсутствия запроса на сервере. Это управление ожиданием клиента. Для GET наш сервер не меняет массив; для POST и DELETE подобное прерывание означало бы неопределённость результата. Поэтому useResource предназначен для чтения и не используется для отправки формы.

Ошибка отмены не показывается как ошибка каталога. В catch проверяется signal.aborted, а также active. Если убрать эти условия, уход на другую страницу может породить ненужное сообщение. Если оставить только подавление ошибок без проверки успешной ветки, поздние данные всё ещё смогут заменить новые.

Функция проверки должна быть стабильной

Массив зависимостей содержит url, parse и attempt. Наши parseNotes и parseNote экспортированы из src/lib/models.js, поэтому их идентичность не создаётся заново при каждом рендере Home. Это существенно: новая inline-функция в аргументе хука выглядела бы как изменившаяся зависимость и запускала бы новую загрузку.

Неправильный вызов useResource('/api/notes', value => parseNotes(value)) добавляет новую обёртку без нового смысла. Она легко создаёт цепочку повторных чтений: изменение состояния вызывает рендер, рендер создаёт функцию, эффект снова запускается. Передача самой parseNotes выражает нужную операцию без такого изменения идентичности.

Нельзя просто удалить parse из зависимостей, чтобы спрятать повторение. Тогда хук перестанет реагировать на действительно изменившийся контракт проверки. Правильное решение определяется владельцем функции: стабильный импорт подходит нашему случаю. Для динамической проверки потребовалось бы отдельно продумать её зависимости.

Возвращаем локальный выбор пользователя

Home теперь может сосредоточиться на фильтре и скрытых идентификаторах. Вот полный файл на этом этапе:

import {useEffect, useState} from 'react';
import {useResource} from '../hooks/useResource.js';
import {parseNotes} from '../lib/models.js';
import LessonList from '../components/LessonList.jsx';
export default function Home() {
  const {status, data, error, reload} = useResource('/api/notes', parseNotes);
  const [topic, setTopic] = useState('all');
  const [hiddenIds, setHiddenIds] = useState([]);
  useEffect(() => {
    const previous = document.title;
    document.title = `Учебный каталог: ${topic}`;
    return () => {document.title = previous;};
  }, [topic]);
  function hideNote(id) {setHiddenIds(current => current.includes(id) ? current : [...current, id]);}
  const visible = (data ?? []).filter(note => !hiddenIds.includes(note.id) && (topic === 'all' || note.topic === topic));
  return <section id="catalog"><h1>Учебный каталог</h1>
    <div className="filter">{['all', 'html', 'css', 'javascript'].map(value =>
      <button key={value} type="button" aria-pressed={topic === value} onClick={() => setTopic(value)}>{value}</button>)}</div>
    {status === 'loading' && <p role="status">Загружаем карточки…</p>}
    {status === 'error' && <div role="alert" className="error"><p>{error?.status === 404 ? 'Адрес API не найден.' : 'Не удалось получить каталог.'}</p><button type="button" onClick={reload}>Повторить чтение</button></div>}
    {status === 'success' && <><LessonList title="Моя подборка" notes={visible} onHide={hideNote} />
      <button type="button" onClick={() => setHiddenIds([])}>Показать скрытые карточки</button></>}
  </section>;
}

Ответ сервера хранится в data, а локальный выбор — в hiddenIds. Кнопка «Убрать из подборки» добавляет идентификатор в отдельный массив и не редактирует серверные записи. Новый GET может принести тот же каталог, но скрытая карточка останется скрытой в текущем компоненте. Кнопка восстановления очищает только hiddenIds.

При повторном скрытии функция проверяет includes и возвращает прежний массив. Это избегает одинаковых идентификаторов в локальном выборе. При уходе с Home и новом монтировании выбор начнётся заново: сохранения в профиле или localStorage нет. Такой результат нужно сообщать точно, не называя настройку постоянной.

Если два компонента вызовут useResource с одним адресом, у каждого будут собственные attempt и controller. Повторное чтение одного не обновит второй автоматически. Это легко проверить по расположению useState внутри функции: каждый вызов связан со своим местом в дереве React. Для общего кеша потребовался бы внешний владелец данных с отдельной политикой актуальности и уведомления потребителей.

Границы этого хука

UseResource не реализует кеширование, объединение параллельных запросов, автоматические повторы, ограничение времени GET и постраничную загрузку. Он нужен, чтобы последовательно объяснить владение состоянием и очистку. Для большого продукта выбор библиотеки работы с данными потребует отдельного сравнения требований.

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

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