---
title: "Turbo Frames с нуля: интерактивный Rails без SPA"
date: 2026-08-17T14:30:00
tags: [rails, ruby, hotwire, turbo, stimulus, frontend, htmx, react]
description: "Как Turbo Frames обновляют части страницы готовым HTML, чем они отличаются от Turbo Streams, htmx, Livewire и React Server Components, и какие ошибки проявляются в реальном интерфейсе."
---

![Turbo Frames с нуля](img/turbo-frames-preview.jpg)

В Rails можно сделать интерактивный интерфейс без отдельного JSON API, глобального клиентского состояния и React-приложения. Сервер продолжает рендерить HTML, а браузер заменяет только нужный фрагмент страницы.

Недавно я переделывал выбор списков желаний в интернет-магазине автозапчастей. Пользователь нажимает «Сохранить», видит свои списки, отмечает несколько чекбоксов, может тут же создать новый список и подтверждает выбор. Страница при этом не перезагружается.

На этом небольшом сценарии проявились почти все фундаментальные свойства Turbo Frames: границы фрейма, lazy loading, стабильные DOM ID, совместная работа с Turbo Streams и состояние, которое существует только в браузере.

<!--truncate-->

## Какую проблему вообще решает Turbo

Классический серверный сайт работает просто:

1. Браузер запрашивает URL.
2. Сервер читает базу и строит HTML.
3. Браузер заменяет всю страницу.

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

SPA обычно решает это иначе:

1. JavaScript запрашивает JSON.
2. Хранит состояние в браузере.
3. Компоненты React, Vue или другого фреймворка превращают состояние в DOM.

Интерфейс становится отзывчивым, но появляется вторая система рендеринга. Сервер знает бизнес-правила и данные, клиент знает, как из них собрать экран, а контракт JSON должен согласовать эти два мира.

[Hotwire](https://hotwired.dev/) предлагает третью формулировку: **передавать по сети не данные для интерфейса, а уже готовый HTML**. Сервер остаётся владельцем представления, а небольшой универсальный JavaScript знает, какой кусок документа заменить.

Всю схему можно свести к одному потоку:

```mermaid
flowchart LR
    B[Браузер] -->|HTTP-запрос| S[Rails-сервер]
    S -->|готовый HTML| B
    B -->|Turbo Drive: весь body| D[Обычная навигация]
    B -->|Turbo Frame: область с ID| F[Частичная навигация]
    B -->|Turbo Stream: DOM-команда| T[Точечное изменение]
    F --> J[Stimulus: локальное поведение]
    T --> J
```

Turbo не превращает серверный HTML в JSON и не вводит отдельное клиентское состояние сам по себе. Он только выбирает, какую часть ответа применить к DOM.

Для обычного web-интерфейса важны четыре механизма:

- **Turbo Drive** ускоряет обычные переходы и формы, заменяя `<body>` без полной перезагрузки страницы;
- **Turbo Frames** создают отдельные области навигации внутри страницы;
- **Turbo Streams** выполняют точечные DOM-операции вроде `replace`, `prepend` и `remove`;
- **Stimulus** добавляет тот JavaScript, который всё же относится к поведению браузера: фокус, drag-and-drop, открытие виджета, работу с внешней библиотекой.

Строго говоря, Drive, Frames и Streams являются механизмами Turbo. Stimulus - отдельная часть Hotwire, а ещё в семейство входит Hotwire Native для мобильных приложений.

## Ментальная модель Turbo Frame

Turbo Frame - это HTML-элемент с уникальным `id`:

```html
<turbo-frame id="wishlist_picker">
  <a href="/wishlists">Выбрать список</a>
</turbo-frame>
```

Ссылка находится внутри фрейма, поэтому Turbo перехватывает клик и делает обычный HTTP-запрос в фоне. В ответе он ищет фрейм **с тем же ID**:

```html
<turbo-frame id="wishlist_picker">
  <form action="/wishlist_items" method="post">
    <label><input type="checkbox" name="wishlist_ids[]" value="1"> Volvo</label>
    <button>Готово</button>
  </form>
</turbo-frame>
```

Затем содержимое старого `wishlist_picker` заменяется содержимым нового. Остальная страница не меняется.

Ответ может быть полным HTML-документом. Turbo всё равно вырежет из него только совпадающий фрейм. Поэтому один Rails action способен обслуживать оба режима:

- обычный переход без Turbo показывает самостоятельную страницу;
- запрос из фрейма обновляет только область внутри страницы.

В Rails разметка обычно создаётся helper-ом:

```ruby
<%= turbo_frame_tag "wishlist_picker" do %>
  <%= link_to "Выбрать список", wishlists_path %>
<% end %>
```

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

## Lazy loading: HTML по требованию

Фрейм может сам загрузить содержимое через `src`:

```html
<turbo-frame
  id="wishlist_picker"
  src="/wishlists/selector?variant_id=42"
  loading="lazy"
>
  <span>Загрузка...</span>
</turbo-frame>
```

Согласно [документации Turbo Frames](https://turbo.hotwired.dev/handbook/frames), `loading="lazy"` откладывает запрос до появления элемента в видимой области. Это особенно удобно для содержимого модального окна, вкладки или блока ниже первого экрана.

В моём случае карточка товара сразу рендерит только кнопку и оболочку нижней панели. Список пользователя загружается при открытии панели. Это даёт два преимущества:

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

Поток lazy loading выглядит так:

```mermaid
sequenceDiagram
    participant V as Виджет
    participant F as Turbo Frame
    participant S as Rails
    participant D as DOM

    V->>F: элемент появился в viewport
    F->>S: GET /wishlists/selector
    S-->>F: HTML с тем же id
    F->>D: заменить только содержимое фрейма
```

Но отсюда следует правило, которое легко пропустить: **GET такого endpoint должен быть безопасным**, то есть не менять данные. Безопасный GET заодно является идемпотентным: повтор запроса не создаёт новых эффектов. Turbo умеет предварительно загружать ссылки, браузеры повторяют запросы, а поисковые роботы ходят по URL. Создавать «список по умолчанию» при открытии селектора было бы ошибкой. Ветка создаёт его только после явного `PUT` по кнопке «Готово».

## Один реальный интерфейс, три вида состояния

Селектор списка желаний выглядит как один виджет, но его состояние живёт в трёх местах.

| Состояние | Где живёт | Пример |
|---|---|---|
| Сохранённое | база данных на сервере | товар уже входит в списки 2 и 5 |
| Черновое | текущий DOM браузера | пользователь снял 2 и отметил 7, но ещё не нажал «Готово» |
| Визуальное | JavaScript и Bootstrap | нижняя панель открыта, поле ввода получило фокус |

Это различие определило архитектуру сильнее, чем внешний вид.

Границы ответственности удобнее представить отдельно:

```mermaid
flowchart TB
    DB[(База данных)] -->|сохранённое членство| R[Rails HTML]
    R -->|рендерит| DOM[DOM браузера]
    DOM -->|черновой выбор| DOM
    ST[Stimulus + Bootstrap] -->|фокус и offcanvas| DOM
    TF[Turbo Frame] -->|заменяет область с ID| DOM
    TS[Turbo Stream] -->|prepend / replace / remove| DOM
```

Если операция затрагивает только DOM, серверу не нужно узнавать о ней. Если данные должны пережить следующий запрос, их нужно явно отправить и сохранить.

Упрощённая структура страницы получилась такой:

```html
<div data-controller="wishlist-selector">
  <turbo-frame id="wishlist_button_variant_42">
    <button>♡ Сохранить</button>
  </turbo-frame>

  <div class="offcanvas">
    <form id="wishlist_button_variant_42_form" action="/wishlist_items/sync_memberships" method="post">
      <input type="hidden" name="_method" value="put">
    </form>

    <turbo-frame
      id="wishlist_button_variant_42_wishlists"
      src="/wishlists/selector?variant_id=42"
      loading="lazy"
    >
      Загрузка...
    </turbo-frame>

    <button type="submit" form="wishlist_button_variant_42_form">
      Готово
    </button>
  </div>
</div>
```

Здесь два разных фрейма:

1. Маленький фрейм кнопки нужен, чтобы после сохранения заменить пустое сердечко на заполненное.
2. Фрейм списка независимо загружает актуальные чекбоксы.

Визуально всё это одна нижняя панель. С точки зрения обновлений это две области с разным временем жизни.

## Почему нельзя просто перерисовать весь список

Пользователь открыл селектор и изменил несколько чекбоксов. Эти изменения ещё не дошли до сервера. Затем он нажал «Новый список», ввёл название и сохранил его.

Наивная реализация после создания списка заново рендерит весь фрейм:

```ruby
render partial: "wishlist_selector_list"
```

Сервер знает сохранённое состояние, но ничего не знает про ещё не отправленные изменения чекбоксов. Полная замена фрейма молча вернёт их к старым значениям.

Проблема не в Turbo. Мы просто перерисовали область, внутри которой находился пользовательский черновик.

Решение в ветке - вернуть две точечные операции Turbo Stream:

```ruby
render turbo_stream: [
  turbo_stream.prepend(
    "wishlist_button_variant_42_wishlists_items",
    partial: "wishlist_selector_row",
    locals: { wishlist: @wishlist, checked: true }
  ),
  turbo_stream.replace(
    "wishlist_button_variant_42_wishlists_quick_create",
    partial: "wishlist_selector_quick_create"
  )
]
```

Новая строка добавляется в начало списка, форма создания очищается, а существующие DOM-узлы с изменёнными чекбоксами остаются на месте.

Это один из главных уроков server-driven UI: **размер ответа должен учитывать не только данные сервера, но и несохранённое состояние браузера**.

## Turbo Frame и Turbo Stream - не одно и то же

Их часто смешивают, потому что оба обновляют части страницы.

| | Turbo Frame | Turbo Stream |
|---|---|---|
| Главная идея | отдельный контекст навигации | команда изменения DOM |
| Как выбирается цель | совпадающий `<turbo-frame id>` | `target` с DOM ID или `targets` с CSS-селектором |
| Типичный источник | ссылка, форма или `src` фрейма | ответ формы, WebSocket или SSE |
| Типичное действие | заменить содержимое фрейма | append, prepend, before, after, replace, update, remove, morph или refresh |
| Нужен ли `<turbo-frame>` у цели | да | нет, подходит обычный HTML-элемент |
| Когда применять | область сама загружает и заменяет себя | один ответ должен изменить несколько точек страницы |

### Операции и количество целей

Названия `replace` и `update` описывают разный размер замены:

- `append` добавляет содержимое в конец целевого элемента;
- `prepend` добавляет содержимое в начало целевого элемента;
- `replace` заменяет сам целевой элемент целиком. Если новый элемент должен оставаться доступным для следующих обновлений, ему возвращают тот же ID;
- `update` заменяет только содержимое целевого элемента, сохраняя его оболочку и атрибуты;
- `before` и `after` вставляют содержимое рядом с целью, до или после неё;
- `remove` удаляет целевой элемент;
- `morph` выполняет замену с сохранением подходящих DOM-узлов, а `refresh` запрашивает обновление страницы.

По умолчанию `target` указывает на один элемент по его DOM ID. Если одну и ту же операцию нужно применить сразу к нескольким элементам, используется `targets` с CSS-селектором:

```html
<turbo-stream action="remove" targets=".flash-message">
</turbo-stream>
```

У Turbo Streams есть и второй, более важный для Rails, уровень пакетного обновления: **один HTTP-ответ может содержать любое количество `<turbo-stream>` элементов**. Поэтому в одном ответе можно выполнить разные действия над разными целями, например `prepend` для списка и `replace` для формы. Rails превращает массив `turbo_stream` в такую последовательность команд, а Turbo применяет их по порядку.

После нажатия «Готово» сервер в моём примере меняет сразу две независимые области:

- сердечко конкретного товара;
- счётчик списков в навигации.

Это естественный Turbo Stream response с двумя `replace`. Оборачивать счётчик навигации во фрейм только ради stream-обновления не нужно. На это отдельно указывает [документация Turbo Streams](https://turbo.hotwired.dev/handbook/streams).

Короткое правило:

- если ссылка или форма «путешествует» внутри своей области, начинайте с Frame;
- если сервер должен разослать несколько точечных изменений, используйте Stream;
- если нужен только локальный эффект браузера, например поставить фокус, используйте Stimulus.

## Зачем здесь всё-таки нужен Stimulus

Turbo не пытается заменить весь JavaScript. Он забирает сетевое взаимодействие и рендеринг, но не обязан управлять Bootstrap-компонентами.

В текущей ветке Stimulus делает три небольшие вещи:

1. Переносит offcanvas в `<body>`, чтобы `z-index` карточки товара не запирал панель внутри нового stacking context.
2. При каждом открытии вызывает `frame.reload()`, чтобы показать актуальные списки.
3. Другой контроллер ставит фокус в поле после завершения Bootstrap-анимации `collapse`.

То есть бизнес-состояние не дублируется в JavaScript. Там остаётся только поведение DOM и стороннего UI-компонента.

Есть и тонкость: Stimulus targets ищутся внутри элемента контроллера. Если переместить target из этого поддерева в `<body>`, после переноса к нему нельзя относиться как к обычному динамическому target. Поэтому ссылка на DOM-элемент сохраняется до переноса, а перемещается только дочерняя панель, не сам корень контроллера. Иначе можно получить цикл `disconnect` / `connect`.

Это уже не основа Turbo Frames, но хороший пример архитектурной границы: Turbo отвечает за HTML по сети, Stimulus - за жизненный цикл элементов в браузере.

## Семь правил, которые экономят время

### 1. ID является контрактом

Запрос из `turbo-frame#abc` ожидает в ответе `turbo-frame#abc`. Если совпадающего элемента нет, Turbo считает ответ непригодным для фрейма и показывает ошибку `Content missing`.

В Rails удобно строить ID через `dom_id`:

```ruby
dom_id(variant, :wishlist_button)
# wishlist_button_variant_42
```

Так ID остаётся стабильным и уникальным даже при десятках карточек на странице.

### 2. Выбирайте минимальную заменяемую область

Если после действия меняется только сердечко, не заменяйте всю карточку товара. Большая область повышает шанс потерять фокус, состояние формы, раскрытые элементы и подключённые JS-виджеты.

### 3. GET ничего не создаёт

Lazy loading и prefetch делают фоновые GET-запросы менее предсказуемыми. Любое создание, удаление или изменение должно происходить через явную небезопасную HTTP-операцию: POST, PUT, PATCH или DELETE.

### 4. Не путайте состояние базы и состояние DOM

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

### 5. Один partial должен возвращать правильную оболочку

Если endpoint обслуживает frame navigation, его ответ должен включать ожидаемый `<turbo-frame>`. Удобно держать тег в partial, который используется и при первом рендере, и при обновлении.

### 6. Фрейм не обязателен для Stream target

Turbo Stream работает с любым элементом с подходящим `id`. Лишний фрейм меняет поведение вложенных ссылок и форм, поэтому добавлять его «на всякий случай» вредно.

### 7. Проверяйте поведение, а не только HTML ответа

Controller spec проверит правильные stream actions, но не обнаружит потерю несохранённых чекбоксов после замены родителя. Для такого сценария нужен browser test: изменить выбор, создать новый список и убедиться, что прежние отметки сохранились.

## Разве это не htmx?

Очень похоже. [htmx](https://htmx.org/docs/) расширяет HTML атрибутами, которые описывают запрос, событие, цель и способ замены:

```html
<button
  hx-post="/wishlist_items"
  hx-target="#wishlist_button_variant_42"
  hx-swap="outerHTML"
>
  Сохранить
</button>
```

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

Практическая разница:

- **Turbo** особенно естественен в Rails: helpers, MIME type для streams, `respond_to`, broadcasting и соглашения уже встроены в экосистему;
- **htmx** не привязан к серверному фреймворку и обычно явнее описывает поведение прямо на элементе;
- в Turbo меньше атрибутов для стандартного CRUD-потока;
- в htmx проще сделать нестандартный swap без перехода к отдельному формату Turbo Stream.

Идея htmx тоже не возникла вчера. Авторы прямо называют его продолжением [intercooler.js](https://htmx.org/essays/future/), библиотеки на jQuery, которая добавляла серверные взаимодействия через HTML-атрибуты.

## «Но такое же давно было в PHP»

Да, семейство идей старше Hotwire.

В середине 2000-х PHP-библиотека [xajax](https://sourceforge.net/projects/xajax/) позволяла вызвать PHP-функцию из JavaScript и асинхронно изменить часть страницы. Репозиторий содержит copyright с 2005 года. Ответ xajax был ближе к набору XML-команд вроде «присвоить этому элементу такой HTML», чем к сегодняшнему REST-переходу между HTML-представлениями, но ощущение для разработчика было знакомым: сервер решает, что показать, без ручного написания большого клиентского приложения.

В ASP.NET Web Forms был [UpdatePanel](https://learn.microsoft.com/en-us/previous-versions/aspnet/bb386454%28v=vs.100%29): сервер заново выполнял жизненный цикл страницы, а браузер обновлял выбранную область без полного postback. Это очень похоже внешне, хотя протокол, тяжёлое скрытое состояние страницы и модель серверных controls были другими.

Современный PHP-наследник этой линии - [Laravel Livewire](https://livewire.laravel.com/docs/4.x/hydration). Он рендерит Blade на сервере, слушает события браузера и делает AJAX-запросы. Но Livewire является компонентной системой: вместе с HTML он хранит JSON snapshot публичного состояния PHP-компонента, затем гидратирует его на следующем запросе. Turbo Frame проще и более stateless: URL плюс HTML-ответ с совпадающим ID.

Ещё два близких родственника:

- [Unpoly](https://unpoly.com/) добавляет серверным приложениям fragment updates, слои, preload и progressive enhancement независимо от языка;
- [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html) начинает с обычного HTTP + HTML, затем держит stateful-процесс на сервере и отправляет DOM-diff через постоянное соединение.

Все они оставляют рендеринг ближе к серверу, но по-разному отвечают на вопрос «где живёт состояние между действиями».

## А что происходит в React и Next.js

Здесь важно различать три технологии, которые часто называют одним словом SSR.

**Классический SSR** рендерит начальный HTML на сервере. После загрузки React гидратирует разметку, и дальнейшие действия обычно снова обслуживает клиентское приложение. Сам по себе SSR не является аналогом Turbo Frames.

**React Server Components** выполняются до bundling в серверном окружении и не попадают в клиентский bundle. Они могут читать данные рядом с источником и передавать результат клиентским компонентам. Но это не «вернуть обычный HTML и вставить по ID». Фреймворк передаёт специальное представление дерева компонентов.

**Next.js App Router** по умолчанию строит страницы и layouts из Server Components. При следующей навигации сервер генерирует **RSC payload**, а клиентский router объединяет его с уже существующим деревом, сохраняя состояние общих layouts. Next.js добавляет prefetch, streaming, Suspense и client-side transitions.

По цели это сосед Turbo: меньше клиентского кода, серверный рендеринг и частичные обновления. По устройству это другой уровень абстракции:

| | Turbo Frames | Next.js App Router + RSC |
|---|---|---|
| Единица композиции | область HTML с ID | дерево React-компонентов и route segments |
| Формат обновления | обычный HTML | RSC payload плюс HTML для первого ответа |
| Клиентская модель | DOM и универсальный Turbo runtime | React runtime, reconciliation и client components |
| Сервер | любой, способный вернуть HTML | React-фреймворк с поддержкой RSC |
| Локальная интерактивность | Stimulus или обычный JS | Client Components, hooks и state |

Поэтому фраза «Vercel теперь тоже вернулся к серверу» в целом верна, но технологии не стали одинаковыми. Turbo развивает гипермедийную модель браузера. React переносит границу выполнения компонентов между сервером и клиентом, сохраняя React как модель интерфейса.

## Когда Turbo Frames подходят хорошо

Turbo особенно убедителен, если:

- приложение уже рендерит HTML на сервере;
- интерфейс в основном состоит из форм, списков, карточек, фильтров и CRUD;
- бизнес-правила и авторизация живут на сервере;
- важны progressive enhancement и простые HTTP-сценарии;
- команда хочет избежать дублирования типов, API и шаблонов на двух сторонах.

Не стоит насильно применять их к интерфейсу, где:

- большинство действий должно мгновенно работать без сети;
- есть сложный граф локального состояния с undo/redo;
- выполняется тяжёлая клиентская визуализация, редактор, canvas или игра;
- приложение должно долго работать offline;
- один экран непрерывно комбинирует данные из множества независимых backend API прямо в браузере.

Граница не обязана проходить по всему приложению. Каталог, checkout и настройки могут работать на Turbo, а сложный редактор внутри одной страницы - быть React-компонентом.

## Что я вынес из этой ветки

До этой задачи Turbo Frames казались мне просто «iframe без iframe»: сервер вернул кусок HTML, браузер его вставил. Механика действительно настолько проста, но проектирование строится вокруг более глубокого вопроса: **кто владеет состоянием в каждый момент действия?**

В селекторе wishlist ответ оказался таким:

- база владеет сохранённым членством товара в списках;
- DOM временно владеет ещё не подтверждёнными чекбоксами;
- Turbo владеет доставкой и заменой серверного HTML;
- Stimulus владеет фокусом и жизненным циклом Bootstrap offcanvas.

После такого разделения решения становятся почти механическими:

- загрузить свежий список - lazy Turbo Frame;
- добавить одну строку, не потеряв черновик - `turbo_stream.prepend`;
- обновить сердечко и навигацию - два stream `replace`;
- поставить курсор в поле - шесть строк Stimulus;
- создать список по умолчанию - только после явного PUT, не на GET.

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

## Ссылки

- [Hotwire: HTML over the wire](https://hotwired.dev/)
- [Turbo Handbook: Frames](https://turbo.hotwired.dev/handbook/frames)
- [Turbo Reference: Streams](https://turbo.hotwired.dev/reference/streams)
- [Turbo Handbook: Drive](https://turbo.hotwired.dev/handbook/drive)
- [htmx documentation](https://htmx.org/docs/)
- [Laravel Livewire: Hydration](https://livewire.laravel.com/docs/4.x/hydration)
- [Phoenix LiveView](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html)
- [React Server Components](https://react.dev/reference/rsc/server-components)
- [Next.js: Linking and Navigating](https://nextjs.org/docs/app/getting-started/linking-and-navigating)
