---
title: "Stimulus с нуля: JavaScript, который живёт в разметке"
date: 2026-08-19T13:30:00
tags: [hotwire, stimulus, javascript, frontend, rails, turbo]
description: "Как Stimulus связывает готовый HTML с поведением в браузере: контроллеры, targets, values, actions, outlets, жизненный цикл и типичные ошибки. С примерами, которые можно запустить прямо в одном файле."
---

![Stimulus с нуля](img/stimulus-s-nulya-preview.jpg)

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

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

Если вы уже читали [Turbo Frames с нуля](/ru/blog/tech/frontend/turbo-frames-s-nulya/), то Stimulus - это вторая половина той же истории: Turbo отвечает за HTML, приезжающий по сети, Stimulus - за поведение элементов уже в браузере.

<!--truncate-->

## Начнём с боли

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

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

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

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

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

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

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

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

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

[Stimulus](https://stimulus.hotwired.dev/) решает ровно эти четыре вещи. Он не рисует 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](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) - браузерный механизм слежения за изменениями DOM. Дальше он реагирует на появление и исчезновение элементов с нужными атрибутами:

```mermaid
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 не нужно.

```html
<!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"`. Каждый получит свой независимый экземпляр. Это основной способ переиспользования: не наследование, а композиция мелких контроллеров.

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

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

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

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

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

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

```mermaid
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()` - место для всего, что должно произойти при появлении блока на экране. Например, привести кнопку в состояние, соответствующее выбранному пункту списка:

```javascript
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()` - это уборка. Всё, что вы завели вручную и что переживает элемент, надо остановить именно здесь:

```javascript
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`. Объявляем список имён, получаем три вещи на каждое:

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

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

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

```javascript
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.

```javascript
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`):

```html
<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-шаблоне это обычно строится хелпером, и на выходе получается тот же атрибут:

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

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

```javascript
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->`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```html
<button data-action="click->picker#choose" data-code="EE">Эстония</button>
```

```javascript
choose(event) {
  this.inputTarget.value = event.currentTarget.dataset.code
}
```

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

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

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

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

```html
<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>
```

```javascript
// 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 создаёт зависимость: карточка знает, что где-то есть корзина. Иногда это лишнее - например, когда автокомплит сообщает «пользователь выбрал вариант», а кто это слушает, зависит от страницы. Тогда лучше событие:

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

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

```html
<div data-controller="variant-search"
     data-action="variant-search:selecting->order-form#fill">
```

```javascript
fill(event) {
  this.idInputTarget.value = event.detail.id
}
```

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

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

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

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

```javascript
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('')
  }
}
```

```html
<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».

```mermaid
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 и показывает её мгновенно, когда пользователь нажимает «назад». Копия делается с **изменённого** состояния, включая то, что пользователь напечатал в поля. Поэтому вернувшись назад, человек может увидеть свой старый поисковый запрос там, где ожидал пустое поле.

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

```javascript
export default class extends Controller {
  // Перед каждым рендером возвращаем полям значение по умолчанию,
  // иначе из кэша Turbo приезжает то, что пользователь напечатал ранее.
  beforeRender(event) {
    event.detail.newBody.querySelectorAll('[data-default]').forEach((input) => {
      input.value = input.dataset.default
    })
  }
}
```

```html
<form data-controller="search"
      data-action="turbo:before-render@window->search#beforeRender">
  <input name="q" data-default="">
</form>
```

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

```javascript
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()` и вставьте элемент обратно. Убедитесь, что порядок вызовов именно такой, как вы ожидали.

## Ссылки

- [Официальная документация Stimulus](https://stimulus.hotwired.dev/handbook/introduction) - короткая, читается за вечер
- [Справочник по targets, values, outlets и actions](https://stimulus.hotwired.dev/reference/controllers)
- [Turbo Frames с нуля](/ru/blog/tech/frontend/turbo-frames-s-nulya/) - вторая половина Hotwire
- [MutationObserver на MDN](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) - механизм, на котором всё построено
