Stimulus с нуля: JavaScript, который живёт в разметке

Stimulus с нуля

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

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

Если вы уже читали Turbo Frames с нуля, то Stimulus - это вторая половина той же истории: Turbo отвечает за HTML, приезжающий по сети, Stimulus - за поведение элементов уже в браузере.

Начнём с боли

Представим карточку товара с кнопкой «в корзину». Первая версия кода почти всегда выглядит так:

<button onclick="addToCart(42)">В корзину</button>

или чуть «взрослее»:

$(document).ready(function () {
  $('.add-to-cart').on('click', function () {
    // ...
  });
});

Работает. Проблемы приходят позже, и их четыре.

Первая: код не знает, когда элемент появился. $(document).ready срабатывает один раз. Если карточку подгрузили аяксом, отрисовали после фильтрации или вернули в модальном окне, обработчик на неё не навесится. Начинается ручное «переинициализировать после загрузки», и каждый новый кусок динамики про это надо помнить.

Вторая: обработчики дублируются. Если инициализацию всё же вызвать дважды, клик отработает два раза, и в корзину попадут два товара. Найти это тяжело, потому что баг зависит от истории навигации пользователя, а не от кода на экране.

Третья: JS ищет элементы по всей странице. $('.add-to-cart') - это глобальный поиск. Переименовали класс в вёрстке ради стилей - сломали логику. Отрисовали две карточки рядом - обработчик не знает, к какой из них относится нажатая кнопка, и начинается путешествие по closest() и parentNode.

Четвёртая: непонятно, что на элементе висит. Открываешь вёрстку, видишь <div class="card"> и не знаешь, есть ли у него поведение. Чтобы выяснить, надо грепать по всему JS.

Stimulus решает ровно эти четыре вещи. Он не рисует HTML и не хранит состояние приложения. Он занимается одним: связывает уже существующую разметку с классами JavaScript и следит за тем, чтобы связь появлялась и исчезала вместе с элементами.

Небольшая историческая справка: как индустрия пришла к Stimulus

Чтобы понять, почему Stimulus выглядит именно так, полезно вспомнить, от чего он отталкивается.

2006-2010: эпоха jQuery. Изначальная задача была не в архитектуре, а в том, что браузеры несовместимы между собой. jQuery дал одинаковый API поверх IE6 и Firefox, и на годы стал стандартом де-факто. Модель работы простая: сервер рисует HTML, скрипт находит нужные элементы селекторами и что-то с ними делает. Ровно ту модель мы и разбирали в предыдущем разделе - вместе с её четырьмя проблемами.

2010-2012: появляется состояние на клиенте. Интерфейсы начали жить дольше одной страницы, и оказалось, что «найти элемент и поменять его» плохо масштабируется. Backbone.js (2010), Knockout, AngularJS (2010), Ember (2011) принесли модели, привязку данных и роутер в браузере. Логика начала переезжать с сервера на клиент.

2013-2019: фронтенд становится отдельным приложением. React (2013) предложил формулу «UI - функция состояния» и виртуальный DOM, Vue (2014) сделал ту же идею дружелюбнее. К моменту появления хуков (2019) типичный проект выглядел так: сервер отдаёт JSON API, клиент - самостоятельное приложение со сборкой, своим роутером, стором и, при желании, серверным рендерингом с гидратацией. Для сложных продуктов это оправдано. Для интернет-магазина, где 90% страниц - обычные списки и карточки, цена такого разделения оказалась заметной: два приложения, две модели данных, две команды.

2018-2021: маятник качается обратно. Почти одновременно появляются подходы, возвращающие HTML на сервер, но оставляющие интерфейс живым: Phoenix LiveView (2018) в мире Elixir, Stimulus (2018) и Turbo/Hotwire (2020) в Rails, Livewire (2019) в Laravel, Alpine.js (2019) как «jQuery с состоянием», htmx (2020, выросший из intercooler.js) как расширение HTML гипермедиа-атрибутами. Показательно, что и сам React пришёл к похожей мысли: React Server Components и app router в Next.js - это тоже про «рендерим на сервере, отправляем разметку».

Stimulus в этой картине - не революция, а сознательный откат к модели jQuery, из которой убрали её четыре родовые травмы.

Где Stimulus находится сейчас

Проект вырос внутри Basecamp: репозиторий открыли в декабре 2016, публичный релиз 1.0 состоялся в январе 2018.

Версия Дата Что произошло
1.0 январь 2018 Публичный релиз, извлечён из кода Basecamp
1.1 август 2018 Стабилизация API
2.0 декабрь 2020 Выходит вместе с анонсом Hotwire
3.0 сентябрь 2021 Переезд в пакет @hotwired/stimulus, отказ от обязательного Webpacker
3.2 ноябрь 2022 Появляются outlets
3.2.2 август 2023 Последний на сегодня релиз

Три года без нового релиза выглядят тревожно, пока не посмотреть, что именно не выпускается. Репозиторий активен - правки в документации, обновления зависимостей, - но API считается законченным: у фреймворка около десяти публичных понятий, и добавлять туда нечего. Для библиотеки, которая сознательно не растёт, отсутствие мажорных версий - скорее признак завершённости, чем заброшенности. С Rails 7 (декабрь 2021) Hotwire стал вариантом по умолчанию, так что каждое новое Rails-приложение приезжает со Stimulus из коробки.

Насчёт популярности иллюзий строить не стоит. Цифры на август 2026 года:

Пакет Загрузок с npm в неделю Звёзд на GitHub
React ~115 млн -
jQuery ~14,7 млн ~60 тыс.
@hotwired/turbo ~925 тыс. ~7,4 тыс.
@hotwired/stimulus ~746 тыс. ~13 тыс.
Alpine.js ~464 тыс. ~32 тыс.
htmx ~197 тыс. ~49 тыс.

Читать эту таблицу нужно осторожно: у htmx и Alpine значительная часть использования идёт через CDN мимо npm, поэтому их доля занижена, а звёзды на GitHub измеряют скорее интерес, чем внедрение. Но порядок величин ясен: Stimulus - это примерно один процент от масштаба React и прочно нишевая история, почти целиком совпадающая с миром Rails.

Практический вывод такой. Stimulus не выигрывает по популярности и не пытается: у него нет ни экосистемы компонентов, ни рынка готовых решений, ни толпы кандидатов в резюме. Его берут не вместо React, а вместо самописного $(document).ready в проекте, где HTML и так рисует сервер. Если это ваш случай - дальше по тексту разберёмся, как он устроен.

Ментальная модель: HTML - источник истины

В React или Vue главный - JavaScript: состояние живёт в компоненте, а DOM является его производной. Изменили состояние - фреймворк перерисовал разметку.

В Stimulus всё наоборот. Главный - HTML, который прислал сервер. JavaScript только «подключается» к нему через data-атрибуты. Состояние по возможности хранится в самом DOM: в значении инпута, в наличии CSS-класса, в атрибуте.

Под капотом это устроено предельно просто. При старте Stimulus вешает на документ MutationObserver - браузерный механизм слежения за изменениями DOM. Дальше он реагирует на появление и исчезновение элементов с нужными атрибутами:

sequenceDiagram
    participant S as Сервер
    participant D as DOM
    participant M as MutationObserver
    participant C as Контроллер

    S->>D: HTML с data-controller="hello"
    D->>M: узел добавлен
    M->>C: создать экземпляр класса
    C->>C: initialize()
    C->>C: connect()
    C->>D: навесить слушатели из data-action
    Note over D,C: элемент живёт и реагирует на события
    D->>M: узел удалён
    M->>C: disconnect()
    C->>D: снять слушатели из data-action

Отсюда следует главное свойство: вам всё равно, откуда взялся элемент. Пришёл он в первичном HTML, приехал внутри Turbo Frame, был вставлен через innerHTML из другого кода - connect() вызовется в любом случае, ровно один раз на элемент. Проблемы «первая» и «вторая» из предыдущего раздела исчезают не потому, что вы их аккуратно обошли, а потому что их больше нельзя воспроизвести.

Первый контроллер за две минуты

Сохраните это в файл index.html и откройте в браузере. Никакой сборки, Node и Rails не нужно.

<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
  <div data-controller="hello">
    <input data-hello-target="name" type="text" placeholder="Ваше имя">
    <button data-action="click->hello#greet">Поздороваться</button>
    <p data-hello-target="output"></p>
  </div>

  <script type="module">
    import { Application, Controller } from 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js'

    // кладём в window, чтобы дёргать из консоли браузера
    window.Stimulus = Application.start()

    Stimulus.register('hello', class extends Controller {
      static targets = ['name', 'output']

      greet() {
        this.outputTarget.textContent = `Привет, ${this.nameTarget.value}!`
      }
    })
  </script>
</body>
</html>

Здесь уже видны все три базовых понятия.

data-controller="hello" отмечает элемент, за который отвечает контроллер. Этот элемент доступен внутри класса как this.element, и он же ограничивает зону видимости: контроллер не видит ничего снаружи себя.

data-hello-target="name" помечает интересный элемент внутри. В классе он превращается в this.nameTarget. Обратите внимание на формат атрибута: data-[имя контроллера]-target. Это нужно, чтобы вложенные контроллеры не воровали друг у друга элементы.

data-action="click->hello#greet" читается как «по событию click вызвать метод greet контроллера hello». Ни addEventListener, ни отписки писать не нужно - Stimulus навесит слушатель при подключении и снимет при удалении элемента.

Ключевая мысль: посмотрев только на HTML, вы уже знаете, какое поведение у блока и по какому событию что вызывается. Не нужно искать это в JS-файлах.

Попробуйте: откройте консоль браузера и выполните Stimulus.debug = true. Stimulus начнёт логировать каждое подключение элемента и каждый вызов метода - очень удобно, когда что-то «не срабатывает».

Соглашения об именах

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

Файл Идентификатор В разметке
hello_controller.js hello data-controller="hello"
cart_badge_controller.js cart-badge data-controller="cart-badge"
car_picker_plate_controller.js car-picker-plate data-controller="car-picker-plate"

Внутри класса имена уже camelCase: target countryIso2 в разметке пишется как data-car-picker-plate-target="countryIso2", а в коде читается как this.countryIso2Target.

На один элемент можно повесить несколько контроллеров через пробел: data-controller="analytics tooltip". Каждый получит свой независимый экземпляр. Это основной способ переиспользования: не наследование, а композиция мелких контроллеров.

Жизненный цикл: три метода, которые надо знать

У контроллера есть несколько зарезервированных методов, вызываемых самим фреймворком:

export default class extends Controller {
  initialize() {
    // один раз за всё время жизни экземпляра
  }

  connect() {
    // каждый раз, когда элемент попадает в DOM
  }

  disconnect() {
    // каждый раз, когда элемент уходит из DOM
  }
}

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

sequenceDiagram
    autonumber
    actor U as Пользователь
    participant T as Turbo
    participant D as DOM
    participant C as Экземпляр контроллера

    U->>D: открыл страницу
    D->>C: initialize()
    D->>C: connect()
    Note right of C: приводим блок в нужное состояние
    U->>T: клик по ссылке
    T->>D: заменяет содержимое страницы
    D->>C: disconnect()
    Note right of C: снимаем свои слушатели,<br/>гасим таймеры и запросы
    U->>T: кнопка «назад»
    T->>D: восстанавливает разметку из кэша
    D->>C: initialize() и connect() для нового экземпляра
    Note right of C: свойства класса обнулены,<br/>состояние надо брать из DOM

Ключевой момент на последнем шаге: Turbo вставляет новые узлы, а не оживляет старые, поэтому контроллер создаётся заново. Всё, что вы сохранили в this.какое-то_поле, до этого момента не доживает.

connect() - место для всего, что должно произойти при появлении блока на экране. Например, привести кнопку в состояние, соответствующее выбранному пункту списка:

export default class extends Controller {
  static targets = ['select', 'button', 'addedIcon', 'notAddedIcon']

  connect() {
    this.sync()
  }

  sync() {
    const option = this.selectTarget.selectedOptions[0]
    const added = option.dataset.added === 'true'

    this.buttonTarget.classList.toggle('btn-primary', added)
    this.addedIconTarget.classList.toggle('d-none', !added)
    this.notAddedIconTarget.classList.toggle('d-none', added)
  }
}

Здесь важна деталь, которая многих удивляет: connect() вызывается не один раз. Если элемент вырезали из DOM и вставили обратно, будет disconnect(), затем снова connect(). Поэтому код в connect() должен быть идемпотентным: повторный вызов не должен ломать состояние и не должен вешать второй слушатель на то же событие.

disconnect() - это уборка. Всё, что вы завели вручную и что переживает элемент, надо остановить именно здесь:

connect() {
  this.onOutsideClick = this.onOutsideClick.bind(this)
  document.addEventListener('click', this.onOutsideClick)
}

disconnect() {
  clearTimeout(this.debounceTimer)
  this.abortController?.abort()
  document.removeEventListener('click', this.onOutsideClick)
}

Правило: если слушатель повешен на document или window, если запущен setInterval, если создан объект сторонней библиотеки - за ними надо убрать. Всё, что объявлено через data-action, Stimulus снимает сам.

Типичная ошибка новичка: повесить в connect() слушатель на document и забыть про disconnect(). На обычном сайте это незаметно, а с Turbo пользователь за сессию посещает десятки страниц без перезагрузки - и к концу у него сотня мёртвых обработчиков, каждый из которых удерживает в памяти давно удалённый DOM.

Targets: ссылки на элементы вместо селекторов

Targets заменяют querySelector. Объявляем список имён, получаем три вещи на каждое:

export default class extends Controller {
  static targets = ['child']

  toggle() {
    this.childTarget           // первый найденный (упадёт, если его нет)
    this.childTargets          // массив всех
    this.hasChildTarget        // есть ли хотя бы один
  }
}

Множественная форма нужна чаще, чем кажется. Классический пример - раскрывающийся блок с несколькими скрытыми частями:

export default class extends Controller {
  static targets = ['child', 'child2']

  toggleChild() {
    this.childTargets.forEach((child) => child.classList.toggle('d-none'))
    this.child2Targets.forEach((child2) => child2.classList.add('d-none'))
  }
}

Три важных свойства targets:

  1. Поиск идёт только внутри this.element. Два одинаковых блока на странице не мешают друг другу: каждый контроллер видит свои элементы. Это решает проблему «третью» из начала статьи.
  2. Список динамический. Добавили в DOM новый элемент с нужным атрибутом - он сразу окажется в this.childTargets, никакого обновления кэша не требуется.
  3. this.fooTarget бросает исключение, если элемента нет. Это сделано намеренно: лучше явная ошибка, чем молчаливое undefined. Если элемент опциональный - проверяйте this.hasFooTarget.

Есть и обратная сторона границы контроллера: если переместить target из поддерева контроллера куда-то в body (например, чтобы вынести панель поверх всего), он перестанет быть target. Сохраните ссылку на элемент до переноса или пересоберите разметку иначе.

Values: как передать данные с сервера в JavaScript

Часто JS нужны данные, которые знает только сервер: цена, валюта, идентификатор, настройки виджета. Соблазн - отрендерить <script> с переменной. Правильный способ - values.

export default class extends Controller {
  static values = {
    price: Number,
    currency: String,
    discount: { type: Number, default: 0 },
    item: Object
  }

  report() {
    const total = (this.priceValue - this.discountValue)
    console.log(total, this.currencyValue, this.itemValue.name)
  }
}

В разметке это выглядит так (формат: data-[контроллер]-[имя]-value):

<div data-controller="product-card"
     data-product-card-price-value="19.90"
     data-product-card-currency-value="EUR"
     data-product-card-item-value='{"id":42,"name":"Фильтр"}'>
</div>

Что тут происходит: Number автоматически превращает строку в число, Object и Array парсят JSON, Boolean понимает "true"/"false". Вам не нужно писать parseInt и JSON.parse руками. Для каждого value доступен this.hasFooValue, а необъявленное значение возвращает нейтральный дефолт своего типа (0, "", false, {}).

В Rails-шаблоне это обычно строится хелпером, и на выходе получается тот же атрибут:

%div{data: {controller: 'product-card',
            'product-card-price-value': variant.price,
            'product-card-currency-value': current_currency}}

Второе, более мощное применение values - реакция на изменение. Для каждого значения можно объявить колбэк:

static values = { open: Boolean }

openValueChanged() {
  this.element.classList.toggle('is-open', this.openValue)
}

Теперь достаточно где угодно написать this.openValue = true, и разметка обновится. Получается маленький однонаправленный поток данных: значение живёт в атрибуте DOM (его видно в инспекторе, оно переживает копирование элемента), а рендер описан в одном месте. Колбэк вызывается и при первичном подключении, так что начальное состояние тоже применится.

Попробуйте: добавьте в первый пример static values = { count: Number } и countValueChanged(), который пишет счётчик в outputTarget. Затем в greet() делайте this.countValue++. Обратите внимание, что вы нигде не вызываете рендер вручную - и что атрибут в инспекторе меняется на глазах.

Actions: подробнее про стрелочку

Полный синтаксис дескриптора действия выглядит так:

data-action="событие->контроллер#метод"

Часть про событие можно опустить, если оно очевидно для элемента: у button это click, у input - input, у select - change, у form - submit. То есть data-action="hello#greet" на кнопке равнозначно записи с click->.

Несколько действий разделяются пробелом, порядок сохраняется:

<button data-action="click->cart#add click->analytics#track">Купить</button>

Дальше начинается самое полезное.

Глобальные события. Через @ можно слушать window или document, оставаясь в своём контроллере:

<nav data-controller="nav" data-action="scroll@window->nav#onScroll">

Так делается «прилипающая» шапка: контроллер живёт на элементе меню, но реагирует на прокрутку всей страницы. И, что важно, при уходе элемента из DOM слушатель на window снимется сам.

Тот же приём работает с событиями Turbo:

<div data-controller="search-results"
     data-action="turbo:load@window->search-results#onLoad">

Модификаторы клавиш и опций. Частые случаи вынесены в синтаксис:

<input data-action="keydown.esc->search#dismiss
                    keydown.enter->search#submit
                    click->menu#open:once">

Поддерживаются :prevent (вызовет preventDefault), :stop (остановит всплытие), :once, :passive, :capture. То есть привычное

greet(event) {
  event.preventDefault()
  // ...
}

сокращается до data-action="click->hello#greet:prevent".

Метод получает событие. Первым аргументом всегда приходит event, и чаще всего вам нужен event.currentTarget - элемент, на котором висит действие (в отличие от event.target, который может быть вложенной иконкой). Отсюда простой приём: положить данные прямо на кнопку и прочитать их из dataset:

<button data-action="click->picker#choose" data-code="EE">Эстония</button>
choose(event) {
  this.inputTarget.value = event.currentTarget.dataset.code
}

Как контроллеры общаются между собой

Рано или поздно одному блоку нужно сообщить что-то другому. Классика: карточка товара добавила товар в корзину, а счётчик в шапке должен обновиться. Это разные места DOM и разные контроллеры. У Stimulus есть два ответа.

Outlets: прямая ссылка на другой контроллер

Outlet - это способ получить экземпляр другого контроллера по CSS-селектору:

<div id="cart-badge" data-controller="cart-badge">
  <span data-cart-badge-target="count">0</span>
</div>

<div data-controller="product-card"
     data-product-card-cart-badge-outlet="#cart-badge">
  <button data-action="click->product-card#add">В корзину</button>
</div>
// cart_badge_controller.js
export default class extends Controller {
  static targets = ['count']

  set count(value) {
    this.countTarget.textContent = value
    this.countTarget.classList.add('badge-pop')
    setTimeout(() => this.countTarget.classList.remove('badge-pop'), 600)
  }
}

// product_card_controller.js
export default class extends Controller {
  static outlets = ['cart-badge']

  add(event) {
    this.cartBadgeOutlet.count = 12   // вызывается сеттер соседнего контроллера
  }
}

Обратите внимание: карточка не лезет в чужой DOM и ничего не знает про вёрстку значка. Она вызывает публичный метод, а как отрисовать число и анимацию - дело значка. Это обычная инкапсуляция, просто через DOM.

Как и у targets, есть this.hasCartBadgeOutlet и this.cartBadgeOutlets для множественной формы, а также колбэки cartBadgeOutletConnected() / cartBadgeOutletDisconnected().

События: когда отправитель не должен знать получателя

Outlet создаёт зависимость: карточка знает, что где-то есть корзина. Иногда это лишнее - например, когда автокомплит сообщает «пользователь выбрал вариант», а кто это слушает, зависит от страницы. Тогда лучше событие:

// внутри контроллера автокомплита
selectResult(item) {
  this.dispatch('selecting', { detail: { id: item.id } })
}

this.dispatch - обёртка над CustomEvent, которая автоматически добавляет к имени префикс контроллера. В примере выше на элементе контроллера возникнет событие variant-search:selecting. Подписаться на него можно тем же дескриптором действия:

<div data-controller="variant-search"
     data-action="variant-search:selecting->order-form#fill">
fill(event) {
  this.idInputTarget.value = event.detail.id
}

Событие всплывает, поэтому слушать можно и на любом родителе. Отправитель при этом не знает ни про какие order-form: он просто объявляет, что произошло. Это самый развязанный способ соединить два куска интерфейса.

Правило выбора простое: нужно позвать конкретный известный блок - outlet; нужно сообщить о факте неизвестно кому - dispatch.

Практический пример: поле поиска с подсказками

Соберём то, что обычно и пишут на Stimulus в реальном проекте: поле с автодополнением. Здесь встречаются debounce, отмена устаревших запросов и навигация клавишами.

import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  static targets = ['input', 'results']

  connect() {
    this.items = []
    this.activeIndex = -1
    this.requestId = 0
    this.onOutsideClick = this.onOutsideClick.bind(this)
    document.addEventListener('click', this.onOutsideClick)
  }

  disconnect() {
    this.requestId++          // все ответы «в полёте» станут неактуальными
    this.cancel()
    document.removeEventListener('click', this.onOutsideClick)
  }

  onInput() {
    const query = this.inputTarget.value.trim()
    const requestId = ++this.requestId
    this.cancel()
    this.clear()
    if (query.length < 4) return

    // Ждём паузу в наборе, чтобы не слать запрос на каждую букву
    this.timer = setTimeout(() => this.fetchResults(query, requestId), 100)
  }

  async fetchResults(query, requestId) {
    this.abortController = new AbortController()
    const response = await fetch(`/search.json?q=${encodeURIComponent(query)}`,
      { signal: this.abortController.signal })
    const items = await response.json()

    // Ответ мог прийти позже следующего запроса - тогда он больше не нужен
    if (requestId !== this.requestId) return

    this.items = items
    this.render()
  }

  onKeydown(event) {
    if (this.items.length === 0) return

    if (event.key === 'ArrowDown') {
      event.preventDefault()
      this.activeIndex = (this.activeIndex + 1) % this.items.length
      this.render()
    } else if (event.key === 'Enter' && this.activeIndex > -1) {
      event.preventDefault()
      this.dispatch('selecting', { detail: this.items[this.activeIndex] })
      this.clear()
    }
  }

  cancel() {
    clearTimeout(this.timer)
    this.abortController?.abort()
  }

  clear() {
    this.items = []
    this.activeIndex = -1
    this.resultsTarget.innerHTML = ''
  }

  onOutsideClick(event) {
    if (!this.element.contains(event.target)) this.clear()
  }

  render() {
    this.resultsTarget.innerHTML = this.items.map((item, i) => `
      <button role="option" data-index="${i}"
              class="${i === this.activeIndex ? 'active' : ''}">
        ${item.title}
      </button>`).join('')
  }
}
<div data-controller="autocomplete">
  <input data-autocomplete-target="input"
         data-action="input->autocomplete#onInput keydown->autocomplete#onKeydown">
  <div data-autocomplete-target="results" role="listbox"></div>
</div>

Три момента, ради которых стоит перечитать этот код.

Счётчик requestId вместо блокировки. Сетевые ответы приходят не в том порядке, в каком ушли запросы. Если пользователь напечатал «фил», потом «фильтр», ответ на «фил» может прийти позже и затереть правильные подсказки. Каждому запросу выдаётся номер, и при отрисовке мы проверяем, что он всё ещё последний. AbortController дополнительно обрывает соединение, но одного его недостаточно: гонка возможна и между «отменён» и «уже разобран JSON».

sequenceDiagram
    autonumber
    actor U as Пользователь
    participant C as Контроллер
    participant S as Сервер

    U->>C: набрал «фильт»
    C->>S: запрос №1
    U->>C: дописал «фильтр»
    C->>S: запрос №2
    S-->>C: ответ №2 (быстрый)
    C->>C: 2 === requestId, рисуем
    S-->>C: ответ №1 (медленный)
    C->>C: 1 !== requestId, выбрасываем
    Note over C: без этой проверки на экране<br/>оказались бы подсказки для «фильт»

Инкремент requestId в disconnect(). Пользователь ушёл со страницы, пока запрос летел. Без этой строки колбэк попытается писать в удалённый DOM.

Ручной removeEventListener только для document. Слушатели на input объявлены в разметке, ими занимается Stimulus. А клик вне блока приходится ловить глобально, поэтому и убирать его надо самому.

Попробуйте: замените fetch на Promise с искусственной задержкой (new Promise(r => setTimeout(() => r(fake), Math.random() * 2000))) и уберите проверку requestId. Наберите текст быстро и посмотрите, как подсказки «прыгают» назад к устаревшему варианту. Это самый наглядный способ понять, зачем нужен счётчик.

Stimulus и Turbo: почему обычный <script> перестаёт работать

Если в проекте есть Turbo, обычные inline-скрипты начинают вести себя странно. Причина в том, что Turbo подменяет содержимое страницы, а не перезагружает документ: DOMContentLoaded больше не наступает при переходах, а <script> внутри заменённого фрагмента может не выполниться повторно.

С контроллерами этой проблемы нет по построению: любая новая разметка проходит через MutationObserver, и connect() вызывается.

Но есть более тонкий эффект - кэш Turbo. Уходя со страницы, Turbo сохраняет копию её текущего DOM и показывает её мгновенно, когда пользователь нажимает «назад». Копия делается с изменённого состояния, включая то, что пользователь напечатал в поля. Поэтому вернувшись назад, человек может увидеть свой старый поисковый запрос там, где ожидал пустое поле.

Лечится это подпиской на событие рендера:

export default class extends Controller {
  // Перед каждым рендером возвращаем полям значение по умолчанию,
  // иначе из кэша Turbo приезжает то, что пользователь напечатал ранее.
  beforeRender(event) {
    event.detail.newBody.querySelectorAll('[data-default]').forEach((input) => {
      input.value = input.dataset.default
    })
  }
}
<form data-controller="search"
      data-action="turbo:before-render@window->search#beforeRender">
  <input name="q" data-default="">
</form>

Из того же семейства - виджеты и аналитика, которые не должны срабатывать дважды. Если элемент отправляет событие покупки, самый надёжный способ не отправить его повторно при возврате назад - удалить сам элемент:

onPurchase() {
  track(this.eventValue)
  // Убираем элемент, чтобы перезагрузка или возврат по истории не отправили событие второй раз
  this.element.remove()
}

Обратите внимание, насколько это в духе Stimulus: состояние «событие уже отправлено» хранится не в переменной JS, а в наличии элемента в DOM.

Грабли, на которые наступают все

Состояние в свойствах класса. this.selectedIds = [] живёт ровно до тех пор, пока элемент в DOM. Turbo заменил фрагмент - контроллер пересоздан, массив пуст. Если состояние должно пережить перерисовку, его место в DOM (атрибут, value, скрытое поле) или на сервере.

Работа через document.querySelector. Технически работает, но вы теряете весь смысл: контроллер перестаёт быть переиспользуемым и ломается, когда таких блоков на странице станет два. Внутри своего поддерева - targets, за его пределами - outlets или события.

Слишком крупный контроллер. Если в классе пятнадцать методов и он отвечает за модалку, форму и таблицу сразу, его почти невозможно переиспользовать. Разделите на несколько и повесьте на один элемент через пробел.

Забытый префикс в атрибуте. data-target="name" вместо data-hello-target="name" - самая частая ошибка первых дней. Ничего не происходит, ошибок в консоли нет. Включайте Stimulus.debug = true.

Дефис против camelCase. Идентификатор контроллера в разметке всегда через дефис (cart-badge), имя target и value - всегда camelCase (countryIso2). Перепутать легко, найти трудно.

Тяжёлая работа в connect(). Он вызывается для каждого экземпляра, а карточек на странице может быть пятьдесят. Всё, что можно сделать один раз глобально, делайте один раз глобально.

Когда Stimulus - неправильный выбор

Stimulus сознательно ничего не умеет: у него нет шаблонов, реактивности, роутера и хранилища. Это преимущество ровно до тех пор, пока разметку рисует сервер.

Признаки, что вы упёрлись в границу:

  • в контроллере накопилась своя система шаблонов и сборка строк HTML на десятки строк;
  • одно и то же состояние приходится синхронизировать между несколькими контроллерами;
  • интерфейс должен работать офлайн или без обращений к серверу;
  • нужна сложная анимация переходов между экранами, drag-and-drop сложных списков, редактор.

В этих случаях уместнее полноценный фреймворк или специализированная библиотека - и это нормально: Stimulus спокойно уживается с островками React или Vue на той же странице.

Обратный признак, что вы на верном пути: контроллер помещается на экран, состояние читается из DOM, а сервер остаётся единственным владельцем правды о данных.

Что попробовать дальше

Небольшие упражнения по возрастанию сложности, все делаются в том же одиночном HTML-файле:

  1. Счётчик символов. Контроллер на форме, target на textarea и на подсказку. По input показывать остаток из лимита. Лимит передать через value с дефолтом.
  2. Раскрывающийся блок. Кнопка переключает openValue, а openValueChanged() добавляет и убирает класс. Убедитесь, что состояние видно в атрибуте элемента в инспекторе.
  3. Два независимых блока. Скопируйте разметку из упражнения 2 два раза на одну страницу и убедитесь, что блоки не мешают друг другу.
  4. Общение. Добавьте контроллер-счётчик открытых блоков в шапку и свяжите с ним блоки через dispatch. Затем перепишите на outlets и сравните, какой вариант читается лучше.
  5. Проверка жизненного цикла. Напишите connect() и disconnect() с логом, затем в консоли выполните document.querySelector('#block').remove() и вставьте элемент обратно. Убедитесь, что порядок вызовов именно такой, как вы ожидали.

Ссылки