Перейти к основному содержимому

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Новый проект tasksScaffold с Turbo-формами
2Turbo DriveБыстрая навигация без полной перезагрузки
3Turbo FramesФорма создания внутри страницы
4Turbo StreamsОбновление списка без redirect
5Stimulus toggleФильтр и скрытие блоков
6BroadcastRealtime через Action Cable
7Тесты и отладкаSystem specs, DevTools
МатериалЗачем
Ruby on RailsMVC, маршруты, ActiveRecord
Ruby — о разделеМаршрут по языку и экосистеме
RSpec — практикумSystem tests с Turbo
HTML introФормы, семантика
CSS introКласс hidden, Tailwind
Веб-разработкаREST, фронтенд и бэкенд

Навигация по блоку Ruby

Редактор и терминал

Команды удобно выполнять во вкладке Терминал VS Code (Ctrl+`). Отладка браузера — статья "Отладка".


Шаг 0 — проверка окружения

Убедитесь, что Ruby и Rails готовы:

ruby -v
rails -v

Для новых проектов подойдёт Ruby 3.2+ и Rails 7.1+. Если rails не найден:

gem install rails

Создайте рабочую папку и откройте её в редакторе — дальше все команды из неё.

Windows и WSL

На 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 StreamsPush HTML с сервера (WebSocket / SSE)
StimulusКонтроллеры на data-controller

Философия:

  • богатый UI строится на серверном HTML;
  • JavaScript подключается точечно, где нужна интерактивность;
  • сервер остаётся источником правды для разметки и бизнес-логики.

Шаг 2 — Turbo Drive

Turbo Drive включён автоматически в Rails 7. Ссылки и формы перехватываются; вместо полной перезагрузки подменяется <body>.

Как это выглядит в Network

  1. Откройте DevTools → вкладка Network.
  2. Перейдите по ссылке внутри приложения.
  3. Запрос вернёт полный 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 — подпишитесь на него
Нужен полный reloadTurbo.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Неверный pathrails 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 в контейнер с id tasks;
  • 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.

MIME-тип

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.jsdata-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 targetsDOM-узлы внутри контроллера
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 / webpackLegacy-проекты, общий код с React

Для Hotwire-приложений importmap часто достаточен. Переход на jsbundling:

bundle add jsbundling-rails
bin/rails javascript:install:esbuild

См. также фронтенд и бэкенд.


Hotwire и SPA — когда что выбирать

КритерийHotwireReact SPA
Источник истиныСервер + HTMLClient state
SEOПроще из коробкиSSR/SSG отдельно
Сложность UIУмереннаяВысокая на больших app
RealtimeTurbo Streams + CableWebSocket + client diff
КомандаСильна в RailsСильна в JS/TS

Hotwire подходит, когда:

  • команда сильна в Rails и серверной логике;
  • интерфейс строится из HTML и шаблонов ERB;
  • нужна быстрая доставка фич без тяжёлой npm-экосистемы;
  • достаточно умеренной интерактивности.

Отдельный JSON API и SPA — когда нужны offline-first, сложные клиентские редакторы, canvas-игры. См. REST API.


Полный учебный сценарий — чек-лист

  1. rails new shop --css tailwind
  2. Scaffold Product — заметить Turbo в forms
  3. Заменить redirect на turbo_stream при create
  4. Добавить Stimulus toggle для фильтра каталога
  5. Optional: broadcast при изменении stock
  6. 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 — практикум.


Упражнения

  1. Inline edit — по клику на заголовок задачи показывайте форму в Turbo Frame, сохраняйте через stream replace.
  2. Счётчик — Stimulus value count и кнопки +/- без round-trip на сервер.
  3. Optimistic UI — disable кнопку submit до ответа; при ошибке stream update с сообщением.
  4. Pagination — index в frame tasks, ссылки страниц с data-turbo-frame="tasks".
  5. 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 CableRedis adapter, отдельный процесс / worker
CSRFRails включает по умолчанию — не отключайте для HTML forms
Кэш фрагментовcache в partials + Russian doll caching
CDNСтатика через CDN, Turbo Drive всё равно ходит на origin
МониторингЛогируйте 422/500 на turbo_stream endpoints
Безопасность streamАвторизуйте turbo_stream_from по текущему user
Production

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-loadFrame обновился
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 и remove target совпадают
  • Stimulus controller зарегистрирован в index.js
  • System spec проходит headless Chrome
  • Нет javascript_include_tag legacy без importmap

Hotwire в существующем Rails 6 проекте

Миграция по шагам:

  1. Обновите Rails до 7.x по guide.
  2. bundle add turbo-rails stimulus-rails.
  3. bin/rails turbo:install stimulus:install.
  4. Проверьте layout — javascript_importmap_tags или build pipeline.
  5. Постепенно заменяйте UJS remote: true на Turbo.
LegacyHotwire
jquery-ujsTurbo Drive
remote: trueTurbo 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 — расширенный чек-лист

ОбластьДействие
Assetsassets:precompile, CDN fingerprint
CableRedis HA, отдельный процесс
JobsSolid Queue / Sidekiq для broadcast side effects
MonitoringSkylight, Scout — время ответа turbo actions
ErrorsHoneybadger/Sentry — JS + server
SecurityCSP headers, sanitize user HTML в partials
Load testk6 на create/destroy stream endpoints
Deploy без простоя

Turbo Streams backward compatible при добавлении новых targets. Удаление id из DOM ломает старые вкладки — версионируйте stream templates осторожно.


Дополнительные упражнения

  1. Optimistic counter — Stimulus показывает "+1" до ответа server, rollback при 422.
  2. Modal frameturbo_frame_tag "modal" в layout, форма edit открывается поверх страницы.
  3. Sortable list — Stimulus + dragula, PATCH position через fetch.
  4. Dark mode toggle — Stimulus class API + localStorage.
  5. Infinite scroll — intersection observer + turbo frame next page.
  6. 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-приложений

РискЗащита
CSRFprotect_from_forgery — default в Rails
XSS<%= %> escape; sanitize для HTML
Mass assignmentStrong params
IDOR в streamcurrent_user.tasks.find(params[:id])
Open redirectWhitelist 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:

  1. rails new shop --css tailwind
  2. rails g model Product name:string price:decimal stock:integer
  3. rails g controller Products index show
  4. Index с Turbo Frame для quick edit price
  5. Cart как Session или Model LineItem
  6. Checkout form — Turbo Drive disabled для payment gateway
  7. Stimulus quantity stepper
  8. Turbo Stream append to cart sidebar
  9. Action Cable broadcast stock changes
  10. Request specs на cart API
  11. System spec checkout flow
  12. Deploy to Fly.io/Heroku — см. Rails

Каждый шаг — отдельный commit; история git учит rollback Turbo changes.


Глоссарий Hotwire

ТерминОпределение
DriveПерехват навигации, замена body
FrameИзолированный фрагмент DOM
StreamСписок DOM-операций с сервера
StimulusJS контроллер на data-attributes
MorphTurbo 8 page refresh strategy (advanced)
PermanentElement сохраняется между Drive visits

Ещё упражнения (13–20)

  1. Duplicate task — stream append clone partial.
  2. Undo delete — stream prepend soft-deleted row, Stimulus timer.
  3. Keyboard shortcuts — Stimulus keydown@window->tasks#focusSearch.
  4. Print viewdata-turbo="false" link to PDF layout.
  5. Multi-select — Stimulus checkbox + bulk destroy_all stream.
  6. Locale switch — frame reload with I18n.locale.
  7. Rate limit — Rack::Attack на create, stream error message.
  8. 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Убедиться в отсутствии ошибок в консоли

Расширенные упражнения (второй проход)

  1. Inline edit title Turbo Frame replace.

Подсказка к упражнению 13: Начните с минимального изменения, затем добавьте тест. Тема: Inline.

  1. Stimulus count without server roundtrip.

Подсказка к упражнению 14: Начните с минимального изменения, затем добавьте тест. Тема: Stimulus.

  1. Optimistic disable submit until response.

Подсказка к упражнению 15: Начните с минимального изменения, затем добавьте тест. Тема: Optimistic.

  1. Pagination inside frame tasks.

Подсказка к упражнению 16: Начните с минимального изменения, затем добавьте тест. Тема: Pagination.

  1. Broadcast remove second browser.

Подсказка к упражнению 17: Начните с минимального изменения, затем добавьте тест. Тема: Broadcast.

  1. Modal turbo_frame modal layout.

Подсказка к упражнению 18: Начните с минимального изменения, затем добавьте тест. Тема: Modal.

  1. Dark mode Stimulus localStorage.

Подсказка к упражнению 19: Начните с минимального изменения, затем добавьте тест. Тема: Dark.

  1. Infinite scroll intersection observer.

Подсказка к упражнению 20: Начните с минимального изменения, затем добавьте тест. Тема: Infinite.

  1. Export CSV data-turbo false link.

Подсказка к упражнению 21: Начните с минимального изменения, затем добавьте тест. Тема: Export.

  1. 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 — дополнительные рекомендации

#ПрактикаЗачем
1CableCable Redis HA separate process
2AuthorizeAuthorize turbo_stream_from current_user
3FragmentFragment cache Russian doll partials
4MonitorMonitor 422 turbo_stream validation errors
5RateRate limit create destroy endpoints
6CSPCSP allow self scripts importmap
7LoadLoad test stream endpoints k6
8FeatureFeature flag disable broadcast globally

Troubleshooting — расширенная таблица

СимптомВероятная причинаДействие
Сборка падает без текстаКэш или версия NodeОчистить node_modules, lock-файл, переустановить
Тесты flakyПорядок или timingИзолировать example, убрать sleep, добавить wait matchers
Production 502Process не слушает PORTПроверить env PORT и health endpoint
Данные пропали после deployIn-memory store или migrateПодключить БД, migrate deploy
CORS в браузереПрямой URL APIProxy dev или enableCors origin
Медленный первый запросCold start DB poolWarmup health check после deploy
Ошибка подписи iOSCertificate expiredRenew в Developer portal, download profiles
Turbo frame blankId mismatchСверить turbo-frame id в request и response
Prisma client outdatedSchema changednpx prisma generate после migrate
Vite blank prodНеверный base pathПроверить base и URL деплоя

Пошаговый walkthrough — контрольный список

День 1

  1. Шаг 1 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 1: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 2

  1. Шаг 1 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 2: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 3

  1. Шаг 1 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 3: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 4

  1. Шаг 1 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 4: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 5

  1. Шаг 1 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 5: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 6

  1. Шаг 1 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 6: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 7

  1. Шаг 1 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 7: закрепить часть стека Hotwire и Stimulus. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 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 и RESTRuby on Rails
Язык и маршрутRuby — о разделе
HTML-формыHTML intro
CSS для hiddenCSS intro
Realtime альтернативаPhoenix LiveView
JSON APIREST 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 — практикум.


Содержание