Hotwire и Stimulus
Дальше: RSpec — практикум · Ruby on Rails · Ruby — о разделе
Hotwire в Rails — практикум от нуля
Hotwire (HTML Over The Wire) — набор техник Basecamp для быстрого веб-интерфейса без React и Vue. Сервер отдаёт HTML, клиент обновляет DOM через Turbo. Stimulus — минимальный JavaScript-фреймворк для точечной интерактивности: toggle, autocomplete, canvas.
Rails 7+ включает Hotwire по умолчанию. База — Ruby on Rails, Ruby — о разделе и HTML/CSS. Похожий серверный подход — Phoenix LiveView.
| Шаг | Тема | Результат |
|---|---|---|
| 0 | Проверка окружения | Rails 7+, Ruby 3.x |
| 1 | Новый проект tasks | Scaffold с Turbo-формами |
| 2 | Turbo Drive | Быстрая навигация без полной перезагрузки |
| 3 | Turbo Frames | Форма создания внутри страницы |
| 4 | Turbo Streams | Обновление списка без redirect |
| 5 | Stimulus toggle | Фильтр и скрытие блоков |
| 6 | Broadcast | Realtime через Action Cable |
| 7 | Тесты и отладка | System specs, DevTools |
| Материал | Зачем |
|---|---|
| Ruby on Rails | MVC, маршруты, ActiveRecord |
| Ruby — о разделе | Маршрут по языку и экосистеме |
| RSpec — практикум | System tests с Turbo |
| HTML intro | Формы, семантика |
| CSS intro | Класс hidden, Tailwind |
| Веб-разработка | REST, фронтенд и бэкенд |
Навигация по блоку Ruby
- База языка: Ruby — о разделе → Первая программа
- Фреймворк: Ruby on Rails
- Вы здесь: Hotwire и Stimulus
- Следующий шаг: RSpec — практикум
Команды удобно выполнять во вкладке Терминал VS Code (Ctrl+`). Отладка браузера — статья "Отладка".
Шаг 0 — проверка окружения
Убедитесь, что Ruby и Rails готовы:
ruby -v
rails -v
Для новых проектов подойдёт Ruby 3.2+ и Rails 7.1+. Если rails не найден:
gem install rails
Создайте рабочую папку и откройте её в редакторе — дальше все команды из неё.
На Windows удобнее Ruby через WSL2 или RubyInstaller. Путь к проекту без пробелов и кириллицы — меньше сюрпризов с bundler и node.
Шаг 1 — проект и scaffold
Создайте приложение с Tailwind CSS (удобно для hidden и layout):
rails new tasks --css tailwind
cd tasks
bin/rails db:create
bin/rails generate scaffold Task title:string completed:boolean
bin/rails db:migrate
bin/rails server
Откройте http://127.0.0.1:3000/tasks. Уже видно Turbo в действии:
- ссылки между страницами перехватываются Turbo Drive;
- формы отправляются без полной перезагрузки;
- в
app/javascript/application.jsподключены@hotwired/turbo-railsи Stimulus.
Разбор структуры:
| Путь | Роль |
|---|---|
app/views/tasks/ | ERB-шаблоны — источник HTML |
app/controllers/tasks_controller.rb | Логика и ответы |
app/javascript/controllers/ | Stimulus-контроллеры |
config/importmap.rb | Карта ES-модулей без webpack |
Подробнее про MVC и REST — в Ruby on Rails.
Состав Hotwire
| Компонент | Назначение |
|---|---|
| Turbo Drive | Ускорение навигации — partial page replace |
| Turbo Frames | Обновление фрагмента страницы |
| Turbo Streams | Push HTML с сервера (WebSocket / SSE) |
| Stimulus | Контроллеры на data-controller |
Философия:
- богатый UI строится на серверном HTML;
- JavaScript подключается точечно, где нужна интерактивность;
- сервер остаётся источником правды для разметки и бизнес-логики.
Шаг 2 — Turbo Drive
Turbo Drive включён автоматически в Rails 7. Ссылки и формы перехватываются; вместо полной перезагрузки подменяется <body>.
Как это выглядит в Network
- Откройте DevTools → вкладка Network.
- Перейдите по ссылке внутри приложения.
- Запрос вернёт полный HTML, но браузер заменит только body — страница "мигает" меньше, чем при классической навигации.
Отключение Turbo для отдельной ссылки
Полезно для скачивания файлов, OAuth-редиректов и сторонних виджетов:
<%= link_to "Скачать CSV", export_tasks_path, data: { turbo: false } %>
Отключение для формы
<%= form_with model: @task, data: { turbo: false } do |f| %>
...
<% end %>
Прогресс-бар и кэш
Turbo показывает тонкий progress bar сверху при медленных ответах. Snapshot-предпросмотр (back/forward) берёт страницу из кэша — учитывайте при формах с чувствительными данными.
| Ситуация | Рекомендация |
|---|---|
| Скачивание файла | data-turbo="false" |
| Внешний URL с редиректом | data-turbo="false" |
| Stimulus init на каждой странице | Turbo вызывает turbo:load — подпишитесь на него |
| Нужен полный reload | Turbo.visit(url, { action: "replace" }) или отключить Drive |
Шаг 3 — Turbo Frames
Turbo Frame — <turbo-frame id="...">. Клик по ссылке или submit формы внутри frame обновляет только этот фрагмент.
Разметка index
<%# app/views/tasks/index.html.erb %>
<h1>Задачи</h1>
<%= turbo_frame_tag "new_task" do %>
<%= link_to "Новая задача", new_task_path %>
<% end %>
<%= turbo_frame_tag "tasks" do %>
<%= render @tasks %>
<% end %>
Форма создания — только frame
<%# app/views/tasks/new.html.erb %>
<%= turbo_frame_tag "new_task" do %>
<%= form_with model: @task do |f| %>
<%= f.label :title, "Название" %>
<%= f.text_field :title, autofocus: true %>
<%= f.submit "Создать" %>
<%= link_to "Отмена", tasks_path %>
<% end %>
<% end %>
Контроллер — ответ без layout
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def new
@task = Task.new
end
def create
@task = Task.new(task_params)
if @task.save
redirect_to tasks_path
else
render :new, status: :unprocessable_entity
end
end
private
def task_params
params.require(:task).permit(:title, :completed)
end
end
При успешном create Turbo Frame ожидает HTML с тем же id="new_task" или redirect на страницу, где frame есть. При ошибке валидации — render :new вернёт форму с ошибками внутри frame.
Частичный шаблон задачи
<%# app/views/tasks/_task.html.erb %>
<div id="<%= dom_id(task) %>" class="flex gap-2 py-2">
<%= task.title %>
<%= button_to "Удалить", task, method: :delete %>
</div>
dom_id(task) даёт стабильный id вида task_42 — пригодится для Streams.
Target frame из другого места
Ссылка с data-turbo-frame="new_task" откроет форму в указанном frame, даже если ссылка снаружи:
<%= link_to "Быстро добавить", new_task_path, data: { turbo_frame: "new_task" } %>
| Симптом | Причина | Решение |
|---|---|---|
| Frame пустой после submit | Ответ без matching turbo-frame id | Проверьте id в ответе сервера |
| Обновилась вся страница | Ссылка с target="_top" | Уберите target или укажите frame |
| 404 внутри frame | Неверный path | rails routes | grep tasks |
| Layout лишний | layout: false не задан | render layout: false для XHR-like ответов |
Шаг 4 — Turbo Streams
Turbo Streams — набор команд (append, prepend, remove, update, replace) в формате text/vnd.turbo-stream.html. Клиент применяет их к DOM.
Контроллер после create
def create
@task = Task.new(task_params)
respond_to do |format|
if @task.save
format.turbo_stream
format.html { redirect_to tasks_path, notice: "Создано" }
else
format.turbo_stream { render :new, status: :unprocessable_entity }
format.html { render :new, status: :unprocessable_entity }
end
end
end
Шаблон stream
<%# app/views/tasks/create.turbo_stream.erb %>
<%= turbo_stream.prepend "tasks", @task %>
<%= turbo_stream.update "new_task", link_to("Новая задача", new_task_path) %>
Разбор:
prepend "tasks"вставляет partial_taskв контейнер с idtasks;update "new_task"возвращает ссылку "Новая задача" вместо формы;- redirect больше не нужен для UX — список обновился на месте.
Удаление через stream
def destroy
@task = Task.find(params[:id])
@task.destroy
respond_to do |format|
format.turbo_stream
format.html { redirect_to tasks_path }
end
end
<%# destroy.turbo_stream.erb %>
<%= turbo_stream.remove @task %>
Контейнер списка
<div id="tasks">
<%= render @tasks %>
</div>
Id контейнера должен совпадать с первым аргументом turbo_stream.prepend.
Turbo Stream ответ имеет Content-Type text/vnd.turbo-stream.html. Если в Network видите text/html без stream-разметки — проверьте respond_to и наличие файла create.turbo_stream.erb.
Шаг 5 — Stimulus
Stimulus — контроллеры на data-controller, data-action, data-*-target. Имя файла hello_controller.js → data-controller="hello".
Структура после rails new
app/javascript/
├── application.js
└── controllers/
├── index.js
└── hello_controller.js
Hello controller
// app/javascript/controllers/hello_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["output"]
static values = { name: String }
greet() {
this.outputTarget.textContent = `Привет, ${this.nameValue}!`
}
}
Разметка
<div data-controller="hello" data-hello-name-value="Мир">
<button data-action="click->hello#greet">Поздороваться</button>
<span data-hello-target="output"></span>
</div>
Соглашение: data-{identifier}-{target|action|value}.
Toggle — практический пример
// app/javascript/controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["panel"]
toggle() {
this.panelTarget.classList.toggle("hidden")
}
}
<div data-controller="toggle">
<button data-action="click->toggle#toggle">Показать / скрыть</button>
<div data-toggle-target="panel" class="hidden">
Скрытый контент
</div>
</div>
CSS-класс hidden — из Tailwind (display: none) или свой класс в CSS intro.
Фильтр задач — учебное упражнение
// app/javascript/controllers/filter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["item"]
static values = { query: String }
filter() {
const q = this.queryValue.toLowerCase()
this.itemTargets.forEach((el) => {
const text = el.textContent.toLowerCase()
el.classList.toggle("hidden", !text.includes(q))
})
}
}
<div data-controller="filter" data-action="input->filter#filter">
<input type="search" data-filter-target="query" data-action="input->filter#filter">
<div id="tasks">
<% @tasks.each do |task| %>
<div data-filter-target="item"><%= task.title %></div>
<% end %>
</div>
</div>
Жизненный цикл и Turbo
При навигации Turbo заменяет body — Stimulus отключает старые контроллеры и подключает новые. Не храните глобальное состояние только в JS: для данных используйте сервер или data-*-value.
| Элемент | Назначение |
|---|---|
static targets | DOM-узлы внутри контроллера |
static values | Примитивы из data-атрибутов |
static classes | Имена CSS-классов |
connect() | Аналог DOMContentLoaded для элемента |
disconnect() | Очистка таймеров и подписок |
Шаг 6 — Broadcast через Action Cable
Turbo Streams работают и push с сервера — без submit формы. Нужен Action Cable.
Модель
# app/models/task.rb
class Task < ApplicationRecord
after_create_commit -> { broadcast_prepend_to "tasks", partial: "tasks/task", locals: { task: self } }
after_destroy_commit -> { broadcast_remove_to "tasks", target: self }
end
Подписка на странице
<%# app/views/tasks/index.html.erb %>
<%= turbo_stream_from "tasks" %>
<div id="tasks">
<%= render @tasks %>
</div>
Откройте два браузера — создание задачи в одном появится в другом после commit в БД.
Production-заметки
- Redis adapter для Action Cable в production;
- channel authorization — не транслируйте приватные данные на общий stream;
- rate limit на create, если UI realtime.
Подробнее про WebSocket — сеть и интернет.
Importmap и bundler
Rails 7 по умолчанию — importmap-rails (без webpack):
# config/importmap.rb
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
| Подход | Когда выбирать |
|---|---|
| importmap-rails | Небольшой JS, Stimulus-контроллеры |
| jsbundling-rails | Тяжёлые npm-з dependencies, сложный фронтенд |
| esbuild / webpack | Legacy-проекты, общий код с React |
Для Hotwire-приложений importmap часто достаточен. Переход на jsbundling:
bundle add jsbundling-rails
bin/rails javascript:install:esbuild
См. также фронтенд и бэкенд.
Hotwire и SPA — когда что выбирать
| Критерий | Hotwire | React SPA |
|---|---|---|
| Источник истины | Сервер + HTML | Client state |
| SEO | Проще из коробки | SSR/SSG отдельно |
| Сложность UI | Умеренная | Высокая на больших app |
| Realtime | Turbo Streams + Cable | WebSocket + client diff |
| Команда | Сильна в Rails | Сильна в JS/TS |
Hotwire подходит, когда:
- команда сильна в Rails и серверной логике;
- интерфейс строится из HTML и шаблонов ERB;
- нужна быстрая доставка фич без тяжёлой npm-экосистемы;
- достаточно умеренной интерактивности.
Отдельный JSON API и SPA — когда нужны offline-first, сложные клиентские редакторы, canvas-игры. См. REST API.
Полный учебный сценарий — чек-лист
rails new shop --css tailwind- Scaffold
Product— заметить Turbo в forms - Заменить redirect на
turbo_streamпри create - Добавить Stimulus toggle для фильтра каталога
- Optional: broadcast при изменении stock
- System spec на создание продукта — RSpec
Отладка
| Проблема | Проверка |
|---|---|
| Frame не обновляется | turbo_frame_tag id совпадает; ответ без лишнего layout |
| Stream не применяется | MIME text/vnd.turbo-stream.html; cable подключён |
| Stimulus не срабатывает | data-controller имя = файл *_controller.js |
| Двойная отправка формы | disable кнопки submit; data-turbo-submits-with |
| Контроллер не регистрируется | controllers/index.js и importmap |
| После Turbo навигации JS "мертв" | Подписка на turbo:load, не только DOMContentLoaded |
DevTools → Network → фильтр turbo или fetch. Вкладка Elements — ищите <turbo-frame> и <turbo-stream>.
Общие приёмы — разработка и отладка.
Ответ сервера содержит <turbo-frame id="tasks">, а stream пишет в id="tasks_list". Id должны совпадать буква в букву.
Тестирование
System tests (Capybara) с Turbo:
# spec/system/tasks_spec.rb
require "rails_helper"
RSpec.describe "Tasks", type: :system do
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
it "creates a task via turbo frame" do
visit tasks_path
click_on "Новая задача"
fill_in "Название", with: "Изучить Hotwire"
click_on "Создать"
expect(page).to have_content("Изучить Hotwire")
end
end
Request spec для stream:
it "returns turbo_stream on create" do
post tasks_path, params: { task: { title: "Test" } }, headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include("turbo-stream")
end
Подробнее — RSpec — практикум.
Упражнения
- Inline edit — по клику на заголовок задачи показывайте форму в Turbo Frame, сохраняйте через stream
replace. - Счётчик — Stimulus value
countи кнопки +/- без round-trip на сервер. - Optimistic UI — disable кнопку submit до ответа; при ошибке stream
updateс сообщением. - Pagination — index в frame
tasks, ссылки страниц сdata-turbo-frame="tasks". - Broadcast — второй пользователь видит удаление через
broadcast_remove_to.
FAQ
Нужен ли React, если уже есть Hotwire?
Для большинства CRUD-панелей и внутренних админок — нет. React имеет смысл при сложном клиентском состоянии.
Работает ли Hotwire без Rails?
Да. Пакеты @hotwired/turbo и @hotwired/stimulus — обычный npm/importmap. Rails даёт conventions и generators.
Как отключить Turbo глобально?
В application.js: import { Turbo } from "@hotwired/turbo-rails" и Turbo.session.drive = false. Обычно лучше точечное data-turbo="false".
Frames и Streams вместе?
Да. Frame для локальной формы, Stream для обновления списка рядом.
SEO и Turbo Drive?
Поисковики получают полный HTML при первом заходе. Для публичных landing проверьте мета-теги и SSR-critical контент.
Importmap в production?
Да, с CDN или asset pipeline. Кэшируйте fingerprinted assets.
Production
| Тема | Рекомендация |
|---|---|
| Action Cable | Redis adapter, отдельный процесс / worker |
| CSRF | Rails включает по умолчанию — не отключайте для HTML forms |
| Кэш фрагментов | cache в partials + Russian doll caching |
| CDN | Статика через CDN, Turbo Drive всё равно ходит на origin |
| Мониторинг | Логируйте 422/500 на turbo_stream endpoints |
| Безопасность stream | Авторизуйте turbo_stream_from по текущему user |
Broadcast на общий канал без проверки прав — риск утечки данных. Привязывайте stream name к current_user или tenant id.
Для боевого деплоя — HTTPS, секреты в credentials, healthcheck и zero-downtime migrations. Основа деплоя Rails — в Ruby on Rails.
Полный учебный проект — все ключевые файлы
Ниже сводка файлов после прохождения шагов 1–6. Сверяйте свой репозиторий построчно.
Маршруты
# config/routes.rb
Rails.application.routes.draw do
resources :tasks
root "tasks#index"
end
Контроллер — index, create, destroy
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
before_action :set_task, only: [:show, :edit, :update, :destroy]
def index
@tasks = Task.order(created_at: :desc)
end
def new
@task = Task.new
end
def create
@task = Task.new(task_params)
respond_to do |format|
if @task.save
format.turbo_stream
format.html { redirect_to tasks_path, notice: "Задача создана" }
else
format.turbo_stream { render :new, status: :unprocessable_entity }
format.html { render :new, status: :unprocessable_entity }
end
end
end
def destroy
@task.destroy
respond_to do |format|
format.turbo_stream
format.html { redirect_to tasks_path }
end
end
private
def set_task
@task = Task.find(params[:id])
end
def task_params
params.require(:task).permit(:title, :completed)
end
end
Index view — frames и stream subscription
<%# app/views/tasks/index.html.erb %>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">Задачи</h1>
<%= turbo_stream_from "tasks" %>
<div data-controller="filter" class="mb-4">
<input type="search"
placeholder="Фильтр..."
class="border rounded px-3 py-2 w-full"
data-action="input->filter#filter"
data-filter-target="query">
</div>
<%= turbo_frame_tag "new_task" do %>
<%= link_to "Новая задача", new_task_path, class: "text-blue-600" %>
<% end %>
<div id="tasks" class="mt-4 divide-y">
<%= render @tasks %>
</div>
</div>
Partial задачи
<%# app/views/tasks/_task.html.erb %>
<div id="<%= dom_id(task) %>"
data-filter-target="item"
class="flex items-center justify-between py-3">
<span><%= task.title %></span>
<%= button_to "Удалить", task, method: :delete, class: "text-red-600 text-sm" %>
</div>
Inline edit через Frame — расширенное упражнение
<%# app/views/tasks/_task.html.erb — вариант с edit %>
<div id="<%= dom_id(task) %>" class="py-2">
<%= turbo_frame_tag dom_id(task, :edit) do %>
<%= link_to task.title, edit_task_path(task) %>
<% end %>
</div>
<%# app/views/tasks/edit.html.erb %>
<%= turbo_frame_tag dom_id(@task, :edit) do %>
<%= form_with model: @task do |f| %>
<%= f.text_field :title, class: "border rounded px-2" %>
<%= f.submit "Сохранить", class: "ml-2" %>
<% end %>
<% end %>
def update
if @task.update(task_params)
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace(@task, partial: "tasks/task", locals: { task: @task }) }
format.html { redirect_to tasks_path }
end
else
render :edit, status: :unprocessable_entity
end
end
Разбор inline edit:
- каждая строка списка — отдельный frame с id
edit_task_42; - клик открывает форму внутри frame;
- после save stream
replaceподставляет снова ссылку с текстом.
Stimulus filter — полный контроллер
// app/javascript/controllers/filter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["item", "query"]
filter() {
const q = this.queryTarget.value.toLowerCase().trim()
this.itemTargets.forEach((el) => {
const visible = q === "" || el.textContent.toLowerCase().includes(q)
el.classList.toggle("hidden", !visible)
})
}
}
Подключите hidden в Tailwind или добавьте в application.tailwind.css:
.hidden { display: none; }
Action Cable — настройка для broadcast
Gem и adapter
В production Rails использует Redis:
# config/cable.yml
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
development:
adapter: async
test:
adapter: test
Channel authorization (production)
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if (user = User.find_by(id: cookies.encrypted[:user_id]))
user
else
reject_unauthorized_connection
end
end
end
end
Stream name с user id:
<%= turbo_stream_from current_user, "tasks" %>
broadcast_prepend_to user, "tasks", partial: "tasks/task", locals: { task: self }
Расширенная отладка Turbo
События JavaScript
Подпишитесь в application.js для диагностики:
document.addEventListener("turbo:frame-missing", (event) => {
console.warn("Frame missing", event.detail)
})
document.addEventListener("turbo:fetch-request-error", (event) => {
console.error("Turbo fetch error", event.detail)
})
| Событие | Когда срабатывает |
|---|---|
turbo:load | Страница от Turbo Drive загружена |
turbo:frame-load | Frame обновился |
turbo:before-stream-render | Перед применением stream |
turbo:submit-start | Форма отправлена |
Логи Rails
В development включите подробный лог формата:
# config/environments/development.rb
config.action_controller.log_rescues = true
Смотрите в терминале Processing by TasksController#create as TURBO_STREAM.
Checklist перед commit
- Frame id в response совпадает с request
-
dom_idв partial иremovetarget совпадают - Stimulus controller зарегистрирован в index.js
- System spec проходит headless Chrome
- Нет
javascript_include_taglegacy без importmap
Hotwire в существующем Rails 6 проекте
Миграция по шагам:
- Обновите Rails до 7.x по guide.
bundle add turbo-rails stimulus-rails.bin/rails turbo:install stimulus:install.- Проверьте layout —
javascript_importmap_tagsили build pipeline. - Постепенно заменяйте UJS
remote: trueна Turbo.
| Legacy | Hotwire |
|---|---|
jquery-ujs | Turbo Drive |
remote: true | Turbo Frame / Stream |
js.erb responses | *.turbo_stream.erb |
Расширенный FAQ
Turbo Drive ломает third-party script?
Скрипты в <head> без defer могут выполняться дважды. Перенесите init в Stimulus connect() или слушайте turbo:load.
Можно ли смешать React и Hotwire?
Да, island architecture: React mount point внутри страницы, остальное Turbo. Сложность растёт — документируйте границы.
Как кэшировать partial для Streams?
<% cache task do %>
<%= render task %>
<% end %>
Инвалидация через touch ассоциаций.
Pagination с Turbo?
Kaminari + data-turbo-frame="tasks" на ссылках page.
CSRF и Turbo?
Rails meta tag csrf-token — Turbo подставляет автоматически для same-origin forms.
Performance на больших списках?
Paginate, lazy load, или virtual scroll через Stimulus — не render 10k DOM nodes.
Production — расширенный чек-лист
| Область | Действие |
|---|---|
| Assets | assets:precompile, CDN fingerprint |
| Cable | Redis HA, отдельный процесс |
| Jobs | Solid Queue / Sidekiq для broadcast side effects |
| Monitoring | Skylight, Scout — время ответа turbo actions |
| Errors | Honeybadger/Sentry — JS + server |
| Security | CSP headers, sanitize user HTML в partials |
| Load test | k6 на create/destroy stream endpoints |
Turbo Streams backward compatible при добавлении новых targets. Удаление id из DOM ломает старые вкладки — версионируйте stream templates осторожно.
Дополнительные упражнения
- Optimistic counter — Stimulus показывает "+1" до ответа server, rollback при 422.
- Modal frame —
turbo_frame_tag "modal"в layout, форма edit открывается поверх страницы. - Sortable list — Stimulus + dragula, PATCH position через fetch.
- Dark mode toggle — Stimulus
classAPI +localStorage. - Infinite scroll — intersection observer + turbo frame next page.
- Export CSV — link с
data-turbo="false".
ViewComponent поверх ERB (следующий уровень)
Когда partials разрастаются, ViewComponent даёт инкапсуляцию UI:
# Gemfile
gem "view_component"
# app/components/task_row_component.rb
class TaskRowComponent < ViewComponent::Base
def initialize(task:)
@task = task
end
end
<%# app/components/task_row_component.html.erb %>
<div id="<%= dom_id(@task) %>" class="flex justify-between py-2">
<span><%= @task.title %></span>
<%= button_to "Удалить", @task, method: :delete %>
</div>
Использование:
<%= render TaskRowComponent.new(task: task) %>
Turbo Streams с компонентами:
<%= turbo_stream.prepend "tasks", TaskRowComponent.new(task: @task) %>
Плюсы:
- unit test render без Capybara;
- явные параметры вместо скрытых instance variables;
- переиспользование в mailers и PDF.
Tailwind и Hotwire — практические паттерны
Flash через Turbo Stream
<%# app/views/shared/_flash.html.erb %>
<div id="flash" class="rounded p-3 mb-4 <%= flash_class %>">
<%= message %>
</div>
<%# create.turbo_stream.erb %>
<%= turbo_stream.update "flash", partial: "shared/flash", locals: { message: "Создано", flash_class: "bg-green-100" } %>
<%= turbo_stream.prepend "tasks", @task %>
Loading state на форме
<%= form_with model: @task, data: { turbo_submits_with: "Сохранение..." } do |f| %>
Stimulus для disable кнопки:
// submit_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["button"]
submit() {
this.buttonTarget.disabled = true
this.buttonTarget.textContent = "Сохранение..."
}
}
Skeleton placeholder
Пока frame грузится, Turbo показывает [busy] attribute — стилизуйте:
turbo-frame[busy] {
opacity: 0.6;
pointer-events: none;
}
Кэширование Russian Doll
<%# app/views/tasks/_task.html.erb %>
<% cache task do %>
<%= render TaskRowComponent.new(task: task) %>
<% end %>
При update:
class Task < ApplicationRecord
belongs_to :project, touch: true
after_update_commit -> { project.touch }
end
Turbo Stream replace инвалидирует только изменённый partial.
Безопасность Hotwire-приложений
| Риск | Защита |
|---|---|
| CSRF | protect_from_forgery — default в Rails |
| XSS | <%= %> escape; sanitize для HTML |
| Mass assignment | Strong params |
| IDOR в stream | current_user.tasks.find(params[:id]) |
| Open redirect | Whitelist redirect paths |
def set_task
@task = current_user.tasks.find(params[:id])
end
Мониторинг Turbo в production
# config/initializers/lograge.rb (concept)
config.lograge.custom_options = lambda do |event|
{ turbo: event.payload[:format] == "turbo_stream" }
end
Метрики:
- latency p95
create.turbo_stream; - error rate 422 на frame forms;
- Action Cable connections count.
Alert если stream error rate > 1%.
Сценарий "Магазин" — end-to-end (12 шагов)
Полный учебный проект альтернатива tasks:
rails new shop --css tailwindrails g model Product name:string price:decimal stock:integerrails g controller Products index show- Index с Turbo Frame для quick edit price
- Cart как Session или Model
LineItem - Checkout form — Turbo Drive disabled для payment gateway
- Stimulus quantity stepper
- Turbo Stream append to cart sidebar
- Action Cable broadcast stock changes
- Request specs на cart API
- System spec checkout flow
- Deploy to Fly.io/Heroku — см. Rails
Каждый шаг — отдельный commit; история git учит rollback Turbo changes.
Глоссарий Hotwire
| Термин | Определение |
|---|---|
| Drive | Перехват навигации, замена body |
| Frame | Изолированный фрагмент DOM |
| Stream | Список DOM-операций с сервера |
| Stimulus | JS контроллер на data-attributes |
| Morph | Turbo 8 page refresh strategy (advanced) |
| Permanent | Element сохраняется между Drive visits |
Ещё упражнения (13–20)
- Duplicate task — stream
appendclone partial. - Undo delete — stream
prependsoft-deleted row, Stimulus timer. - Keyboard shortcuts — Stimulus
keydown@window->tasks#focusSearch. - Print view —
data-turbo="false"link to PDF layout. - Multi-select — Stimulus checkbox + bulk
destroy_allstream. - Locale switch — frame reload with
I18n.locale. - Rate limit — Rack::Attack на create, stream error message.
- Feature flag — Flipper gem, conditional partial in stream.
Второй проход — расширенный практикум (Hotwire и Stimulus)
Серия мини-туториалов
Туториал 1 — Turbo morph
Команда или API: data-turbo-permanent.
Детали: сохранить элемент при morph.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Turbo morph |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 2 — Turbo confirm
Команда или API: data-turbo-confirm.
Детали: диалог перед delete.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Turbo confirm |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 3 — Lazy frames
Команда или API: loading lazy src.
Детали: отложенная загрузка frame.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Lazy frames |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 4 — Stream append many
Команда или API: turbo_stream.append_all.
Детали: batch DOM updates.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Stream append many |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 5 — Stimulus values
Команда или API: static values Object.
Детали: typed data attributes.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Stimulus values |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 6 — Stimulus classes
Команда или API: static classes Array.
Детали: toggle CSS class names.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Stimulus classes |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 7 — Stimulus outlets
Команда или API: static outlets.
Детали: связь контроллеров.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Stimulus outlets |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 8 — Autocomplete
Команда или API: debounce fetch Stimulus.
Детали: search suggestions.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Autocomplete |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 9 — Sortable
Команда или API: drag drop Stimulus.
Детали: reorder tasks PATCH.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить Sortable |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Туториал 10 — ViewComponent
Команда или API: render component in ERB.
Детали: комposable UI partials.
// пример шага
console.log('ok');
| Шаг | Проверка |
|---|---|
| 1 | Выполнить ViewComponent |
| 2 | Перезапустить dev-сервер |
| 3 | Убедиться в отсутствии ошибок в консоли |
Расширенные упражнения (второй проход)
- Inline edit title Turbo Frame replace.
Подсказка к упражнению 13: Начните с минимального изменения, затем добавьте тест. Тема: Inline.
- Stimulus count without server roundtrip.
Подсказка к упражнению 14: Начните с минимального изменения, затем добавьте тест. Тема: Stimulus.
- Optimistic disable submit until response.
Подсказка к упражнению 15: Начните с минимального изменения, затем добавьте тест. Тема: Optimistic.
- Pagination inside frame tasks.
Подсказка к упражнению 16: Начните с минимального изменения, затем добавьте тест. Тема: Pagination.
- Broadcast remove second browser.
Подсказка к упражнению 17: Начните с минимального изменения, затем добавьте тест. Тема: Broadcast.
- Modal turbo_frame modal layout.
Подсказка к упражнению 18: Начните с минимального изменения, затем добавьте тест. Тема: Modal.
- Dark mode Stimulus localStorage.
Подсказка к упражнению 19: Начните с минимального изменения, затем добавьте тест. Тема: Dark.
- Infinite scroll intersection observer.
Подсказка к упражнению 20: Начните с минимального изменения, затем добавьте тест. Тема: Infinite.
- Export CSV data-turbo false link.
Подсказка к упражнению 21: Начните с минимального изменения, затем добавьте тест. Тема: Export.
- System spec frame create Capybara.
Подсказка к упражнению 22: Начните с минимального изменения, затем добавьте тест. Тема: System.
Расширенный FAQ (второй проход)
Turbo permanent elements?
data-turbo-permanent id stable across visits.
Cable Redis scale?
Redis adapter cluster Action Cable.
Stimulus 3 changes?
Check @hotwired/stimulus release notes.
Turbo iOS Safari?
Test back button snapshot cache.
CSRF meta missing?
csrf_meta_tags in layout required.
Frame breakout?
data-turbo-frame _top full page.
Stream unauthorized?
Authorize stream name per user tenant.
Importmap pin version?
pin '@hotwired/turbo-rails', to versioned CDN.
jsbundling coexist?
Yes hybrid importmap + esbuild packs.
View transitions CSS?
View Transitions API experimental optional.
Production — дополнительные рекомендации
| # | Практика | Зачем |
|---|---|---|
| 1 | Cable | Cable Redis HA separate process |
| 2 | Authorize | Authorize turbo_stream_from current_user |
| 3 | Fragment | Fragment cache Russian doll partials |
| 4 | Monitor | Monitor 422 turbo_stream validation errors |
| 5 | Rate | Rate limit create destroy endpoints |
| 6 | CSP | CSP allow self scripts importmap |
| 7 | Load | Load test stream endpoints k6 |
| 8 | Feature | Feature flag disable broadcast globally |
Troubleshooting — расширенная таблица
| Симптом | Вероятная причина | Действие |
|---|---|---|
| Сборка падает без текста | Кэш или версия Node | Очистить node_modules, lock-файл, переустановить |
| Тесты flaky | Порядок или timing | Изолировать example, убрать sleep, добавить wait matchers |
| Production 502 | Process не слушает PORT | Проверить env PORT и health endpoint |
| Данные пропали после deploy | In-memory store или migrate | Подключить БД, migrate deploy |
| CORS в браузере | Прямой URL API | Proxy dev или enableCors origin |
| Медленный первый запрос | Cold start DB pool | Warmup health check после deploy |
| Ошибка подписи iOS | Certificate expired | Renew в Developer portal, download profiles |
| Turbo frame blank | Id mismatch | Сверить turbo-frame id в request и response |
| Prisma client outdated | Schema changed | npx prisma generate после migrate |
| Vite blank prod | Неверный base path | Проверить base и URL деплоя |
Пошаговый walkthrough — контрольный список
День 1
- Шаг 1 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 2
- Шаг 1 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 3
- Шаг 1 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 4
- Шаг 1 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 5
- Шаг 1 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 6
- Шаг 1 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 7
- Шаг 1 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
Чек-лист самопроверки перед сдачей практикума
-
Проект создаётся с нуля по статье без пропусков шагов
-
CRUD или эквивалентный сценарий работает end-to-end
-
Есть обработка ошибок валидации или 404
-
Данные переживают перезапуск там, где это требуется темой
-
Написан минимум один автоматический тест или system check
-
Production-секция прочитана и применена к деплою или Docker
-
FAQ просмотрен — типичные ошибки воспроизведены и исправлены
-
Связанные материалы открыты для следующего шага обучения
Связанные материалы
| Тема | Материал |
|---|---|
| MVC и REST | Ruby on Rails |
| Язык и маршрут | Ruby — о разделе |
| HTML-формы | HTML intro |
CSS для hidden | CSS intro |
| Realtime альтернатива | Phoenix LiveView |
| JSON API | REST API |
| Тесты | RSpec — практикум |
Что дальше
- Turbo Native — мобильные оболочки поверх Turbo Drive
- ViewComponent — компоненты поверх ERB
- Action Cable — каналы помимо Streams
- Solid Queue / Solid Cable — Rails 8+ адаптеры без Redis (по конфигурации)
Hotwire и Stimulus возвращают Rails-разработку к HTML-first: меньше JSON ради JSON, больше скорости доставки фич. Следующий шаг — автоматизировать проверки в RSpec — практикум.