HTMX возвращает веб-разработку к простой идее: сервер отдаёт HTML, браузер его показывает, а интерактивность добавляется атрибутами в разметке. Вместо того чтобы дублировать шаблоны на клиенте и строить JSON-API, вы возвращаете фрагмент страницы и указываете, куда его вставить. Для форм, списков, фильтров и пагинации этого хватает почти всегда. Разберём рабочие приёмы, серверные ответы и подводные камни.
Как это работает
Атрибут hx-get или hx-post превращает обычный элемент в источник запроса. Сервер отвечает HTML-фрагментом, а HTMX подменяет содержимое цели согласно hx-target и hx-swap. Никакого виртуального DOM и гидратации нет: браузер просто вставляет разметку.
Ключевое следствие — сервер остаётся единственным источником правды. Шаблонизатор один, состояние живёт в базе, а не в сторе на клиенте. Это упрощает отладку: в сетевой панели видно ровно тот HTML, который получил пользователь.
Подключение
Библиотека распространяется одним файлом. В продакшене лучше положить его рядом со статикой и зафиксировать точную версию, чем зависеть от внешнего CDN.
<script src="/static/js/htmx.min.js" defer></script>
<body hx-boost="true">
<a href="/catalog">Каталог</a>
</body>
hx-boost перехватывает обычные ссылки и формы, подменяет только содержимое body и обновляет адресную строку. Страница остаётся рабочей без JavaScript, а с ним — грузится без полной перезагрузки. Это самый дешёвый способ ускорить существующий сайт.
Базовые атрибуты
hx-get,hx-post,hx-put,hx-delete— метод и адрес запроса.hx-target— селектор цели; поддерживаетthis,closest,next,find.hx-swap—innerHTMLпо умолчанию, а такжеouterHTML,beforeend,afterbegin,delete,none.hx-trigger— событие и модификаторы:changed,delay:400ms,throttle,from:,once.hx-indicatorиhx-disabled-elt— индикатор загрузки и блокировка кнопки.
Живой поиск собирается из трёх атрибутов и не требует ни одной строки JavaScript. Задержка delay гасит лишние запросы при быстром наборе.
<input type="search" name="q"
hx-get="/search"
hx-trigger="input changed delay:400ms, search"
hx-target="#results"
hx-push-url="true"
hx-indicator="#search-spin">
<span id="search-spin" class="htmx-indicator">Ищем…</span>
<div id="results" aria-live="polite"></div>
Формы и валидация
Форма отправляется асинхронно, а ответ вставляется в указанный контейнер. Атрибут hx-disabled-elt защищает от двойной отправки, пока идёт запрос.
<form hx-post="/subscribe"
hx-target="#subscribe-result"
hx-swap="innerHTML"
hx-disabled-elt="button"
hx-indicator="#spin">
<label for="email">Почта</label>
<input id="email" type="email" name="email" required>
<button type="submit">Подписаться</button>
<span id="spin" class="htmx-indicator">Отправляем…</span>
</form>
<div id="subscribe-result" aria-live="polite"></div>
Важная деталь: HTMX 2 по умолчанию не подменяет содержимое при ответах 4xx и 5xx. Для ошибок валидации либо возвращайте 200 с фрагментом сообщения, либо настраивайте htmx.config.responseHandling, добавляя код 422 в список подменяемых. Молчаливый отказ формы без объяснения — типичная ошибка новичков.
Серверная часть
На сервере нет ничего специфического: тот же шаблонизатор, только для фрагмента. Пример на Flask показывает возврат частичного шаблона и заголовок-событие.
from flask import Flask, request, render_template, make_response
app = Flask(__name__)
@app.post("/subscribe")
def subscribe():
email = (request.form.get("email") or "").strip()
if "@" not in email:
return render_template("partials/subscribe_error.html"), 200
save_subscriber(email)
resp = make_response(render_template("partials/subscribe_ok.html"))
resp.headers["HX-Trigger"] = "subscriber-added"
return resp
В Django аналог — render_to_string и HttpResponse; пакет django-htmx добавляет удобные хелперы и определение HTMX-запроса по заголовку HX-Request. В Go достаточно html/template и ExecuteTemplate для именованного блока.
Out-of-band обновления
Иногда один ответ должен изменить несколько участков страницы: добавить товар в корзину, обновить счётчик и показать уведомление. Для этого служит hx-swap-oob: элемент с этим атрибутом заменяет одноимённый элемент на странице, а не вставляется в цель.
<div id="cart-count" hx-swap-oob="true">3</div>
<div id="cart-list">
<p>Товар добавлен</p>
</div>
<div id="toasts" hx-swap-oob="beforeend:#toasts">
<div class="toast">Сохранено</div>
</div>
Это снимает необходимость в глобальном состоянии на клиенте. Ответ остаётся обычным HTML, а сервер сам решает, какие блоки обновить.
История и навигация
hx-push-url="true" записывает адрес в историю, hx-replace-url заменяет текущую запись. Чтобы кнопка «назад» работала предсказуемо, укажите контейнер истории через hx-history-elt — по умолчанию им считается body.
- Для пагинации: кнопка с
hx-get="/page/2"иhx-target="#list" hx-swap="afterend". - Для бесконечной прокрутки:
hx-trigger="revealed"на последнем элементе. - Для модальных окон: ответ с
hx-swap="beforeend"в контейнер оверлея. - Для сохранения состояния виджета:
hx-preserve="true".
События и заголовки
Сервер может управлять поведением клиента заголовками: HX-Redirect уводит на другую страницу, HX-Refresh перезагружает её, HX-Retarget меняет цель, HX-Trigger поднимает пользовательское событие. На клиенте их удобно слушать обычным addEventListener.
document.body.addEventListener("htmx:responseError", (event) => {
console.warn("Ошибка", event.detail.xhr.status, event.detail.pathInfo.requestPath);
});
document.body.addEventListener("subscriber-added", () => {
document.querySelector("#counter")?.classList.add("bump");
});
Полезные события: htmx:beforeRequest, htmx:afterSwap, htmx:sendError, htmx:targetError. Последнее почти всегда означает опечатку в селекторе hx-target.
Безопасность
- Экранируйте вывод. HTMX вставляет HTML как есть, поэтому пользовательский текст без экранирования превращается в XSS. Автоэкранирование шаблонизатора должно быть включено.
- CSRF. Токен передавайте через
hx-headersнаbodyили скрытым полем формы; сервер обязан проверять его для всех изменяющих запросов. - Не доверяйте клиенту.
hx-valsиhx-headersвидны и редактируются, поэтому цены и роли проверяются на сервере. - CSP.
hx-onвыполняет код из атрибута, поэтому при строгой политике его лучше не использовать и выносить обработчики в отдельный файл.
Когда HTMX не подходит
Сложные редакторы, drag-and-drop с десятками правил, офлайн-режим и мгновенный отклик без сети требуют клиентского состояния. В таких местах HTMX сочетают с Alpine.js или точечным JavaScript: HTMX отвечает за загрузку фрагментов, а локальную логику берёт на себя небольшой скрипт. Попытка выжать из атрибутов всё подряд приводит к каше из hx-on и скрытых зависимостей.
Отладка
Первым делом включите htmx.logAll() в консоли — он покажет жизненный цикл каждого запроса. Затем проверьте в сетевой панели, что уходит заголовок HX-Request: true и приходит HTML, а не JSON. Если ответ 302, HTMX прозрачно пройдёт по редиректу и вставит финальную страницу, что не всегда ожидаемо; для смены адреса используйте HX-Redirect.
htmx.logAll();
htmx.config.selfRequestsOnly = true;
fetch("/search?q=linux", {headers: {"HX-Request": "true"}});
Итог
HTMX выигрывает там, где интерфейс — это набор форм, списков и фильтров, а команда не хочет поддерживать второй фронтенд. Начните с hx-boost, затем переведите на атрибуты одну форму и один список, проверьте коды ответов и экранирование. Если приложение требует богатого клиентского состояния, добавьте точечный JavaScript, но не тащите фреймворк только ради модной архитектуры.