Отмена запросов и управление состоянием

В современных интерфейсах на базе Stimulus асинхронные HTTP-запросы используются постоянно: автодополнение, подгрузка данных при прокрутке, фильтрация, формы с мгновенной валидацией. Основная сложность заключается в том, что пользовательские действия могут происходить быстрее, чем завершается сетевой запрос. В результате в приложение могут поступать устаревшие ответы, приводящие к неконсистентному состоянию интерфейса.

Типичный пример — поле поиска с автозапросами. Каждый ввод символа инициирует новый запрос, но предыдущие ещё не завершены. Без механизма отмены последний ответ может прийти раньше предыдущего, либо наоборот, что приведёт к отображению неверных данных.

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

Современный JavaScript предоставляет стандартный механизм отмены асинхронных операций — AbortController. Он интегрирован с fetch и рядом других API.

this.controller = new AbortController()

fetch(url, { signal: this.controller.signal })

Вызов this.controller.abort() прерывает запрос и выбрасывает исключение AbortError. Это исключение должно обрабатываться явно, иначе оно будет восприниматься как ошибка выполнения.

В контексте Stimulus контроллера AbortController обычно хранится как часть состояния экземпляра.

Управление жизненным циклом запросов в Stimulus

Stimulus предоставляет чёткий жизненный цикл: connect, disconnect, обработчики событий. Это позволяет аккуратно управлять асинхронными операциями.

Ключевое правило: каждый контроллер отвечает за отмену собственных запросов при уничтожении или замене.

disconnect() {
  if (this.controller) {
    this.controller.abort()
  }
}

Такой подход предотвращает обновление DOM после удаления элемента и утечки памяти.

Повторные запросы и сброс состояния

При инициировании нового запроса предыдущий должен быть отменён. Типовой паттерн:

load() {
  if (this.controller) {
    this.controller.abort()
  }

  this.controller = new AbortController()

  fetch(this.urlValue, { signal: this.controller.signal })
    .then(r => r.json())
    .then(data => this.update(data))
    .catch(error => {
      if (error.name !== "AbortError") {
        throw error
      }
    })
}

Таким образом обеспечивается строгое соответствие: один активный запрос — одно актуальное состояние.

Состояние контроллера как источник истины

Stimulus не навязывает систему управления состоянием, но поощряет локальное состояние внутри контроллера. Для асинхронных операций это особенно важно.

Типичные состояния:

  • загрузка (loading)
  • успешная загрузка (loaded)
  • ошибка (error)
  • отсутствие данных (empty)

Состояние удобно хранить в свойствах экземпляра:

this.loading = true
this.error = null

Или в виде значений Stimulus (static values), если требуется реактивность и доступ из шаблона.

Синхронизация состояния и DOM

DOM не должен обновляться напрямую из асинхронного кода без проверки актуальности состояния. Даже при использовании AbortController возможны ситуации, когда логика усложняется дополнительными условиями.

Практика — проверять, что контроллер всё ещё активен:

if (this.controller.signal.aborted) return

Это особенно важно при цепочках then, таймерах или комбинации fetch и setTimeout.

Обработка ошибок без разрушения состояния

Ошибка запроса не всегда означает ошибку интерфейса. Отмена запроса — ожидаемое поведение и не должна переводить контроллер в состояние ошибки.

catch(error) {
  if (error.name === "AbortError") return

  this.error = error
  this.showError()
}

Таким образом сохраняется предсказуемость: пользовательские действия не приводят к ложным ошибкам.

Конкурирующие запросы и идентификаторы операций

В сложных сценариях используется не только отмена, но и идентификация запросов. Например, когда отмена невозможна (сторонний API) или запросы выполняются параллельно.

this.requestId = crypto.randomUUID()
const currentId = this.requestId

fetch(...)
  .then(data => {
    if (this.requestId !== currentId) return
    this.update(data)
  })

Этот приём дополняет AbortController и защищает от гонок состояний.

Интеграция с values и targets

Состояние загрузки удобно отражать через CSS-классы и targets:

static targets = ["spinner", "content"]

set loading(value) {
  this.spinnerTarget.hidden = !value
  this.contentTarget.hidden = value
}

Асинхронный код изменяет только состояние, а визуальное представление остаётся декларативным.

Очистка и повторная инициализация

При повторном подключении контроллера (например, при навигации Turbo) состояние должно быть инициализировано заново. Нельзя полагаться на прошлые запросы или значения.

connect() {
  this.controller = null
  this.loading = false
  this.error = null
}

Это гарантирует воспроизводимость поведения независимо от истории взаимодействий.

Масштабирование подхода

При росте сложности интерфейса каждый Stimulus-контроллер должен оставаться автономным. Управление отменой запросов и состоянием не выносится в глобальные объекты без необходимости. Вместо этого используется композиция: несколько контроллеров, каждый со своим циклом запросов и собственным AbortController.

Такой подход сохраняет основное преимущество Stimulus — простоту и предсказуемость поведения, даже при активной работе с асинхронными данными.