htmx и возврат к серверному рендерингу
Типичный сценарий: нужен внутренний дашборд для операционной команды. Таблица заказов, фильтры, пара форм. Разработчик открывает Create React App (или Vite с Vue), настраивает роутинг, придумывает API-контракт, пишет state management — и через два дня у него есть архитектура, но нет продукта. htmx предлагает другой путь: HTML, который умеет делать запросы.
Что такое htmx и почему это не «старый подход»
htmx — это библиотека (14 KB gzip), которая расширяет HTML атрибутами для AJAX-запросов, WebSocket, SSE и управления DOM без JavaScript на стороне разработчика. Ключевая идея — hypermedia as the engine of application state (HATEOAS): сервер возвращает HTML-фрагменты, браузер вставляет их в нужное место.
Версия 2.0 вышла в июне 2024 и принесла breaking changes: убрала устаревшие атрибуты hx-on в пользу явного синтаксиса, улучшила обработку событий и добавила hx-validate для нативной HTML5-валидации форм.
Это не возврат к PHP 2005 года. Разница принципиальная: вы по-прежнему пишете чистый Python/Go/Ruby на сервере, используете нормальные шаблоны, тестируете бизнес-логику без браузера. Просто отказываетесь от лишнего слоя сложности.
Ключевые атрибуты: минимальный словарь
Весь htmx умещается в несколько атрибутов. Базовый набор, который покрывает 90% задач:
<!-- Загрузить HTML-фрагмент GET-запросом и вставить вместо элемента -->
<div hx-get="/orders/list"
hx-trigger="load"
hx-target="#orders-container"
hx-swap="innerHTML">
Загрузка...
</div>
<!-- Форма: отправить POST, заменить форму ответом сервера -->
<form hx-post="/orders/create"
hx-target="#order-form"
hx-swap="outerHTML">
<input name="title" required />
<button type="submit">Создать</button>
</form>
<!-- Кнопка с подтверждением -->
<button hx-delete="/orders/42"
hx-target="closest tr"
hx-swap="outerHTML swap:300ms"
hx-confirm="Удалить заказ?">
Удалить
</button>
<!-- Поиск с debounce: запрос через 400 мс после последнего ввода -->
<input type="search"
name="q"
hx-get="/orders/search"
hx-trigger="keyup changed delay:400ms"
hx-target="#search-results"
placeholder="Поиск по заказам..." />
hx-swap контролирует, куда вставляется ответ: innerHTML, outerHTML, beforebegin, afterend, delete. Модель простая и предсказуемая.
Реальный пример: дашборд на FastAPI + htmx + Jinja2
Возьмём конкретную задачу — дашборд заказов с фильтрацией по статусу и пагинацией. На React это минимум 150 строк JS с хуками. На htmx — несколько шаблонов и роутов.
# main.py
from fastapi import FastAPI, Request, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
app = FastAPI()
templates = Jinja2Templates(directory="templates")
# Фейковая БД для примера
ORDERS = [
{"id": i, "title": f"Заказ #{i}", "status": ["new", "processing", "done"][i % 3]}
for i in range(1, 51)
]
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/orders/list", response_class=HTMLResponse)
async def orders_list(
request: Request,
status: str = Query(default=""),
page: int = Query(default=1),
):
per_page = 10
filtered = [o for o in ORDERS if not status or o["status"] == status]
total = len(filtered)
items = filtered[(page - 1) * per_page : page * per_page]
# htmx ожидает HTML-фрагмент, не JSON
return templates.TemplateResponse("partials/orders_list.html", {
"request": request,
"orders": items,
"page": page,
"total_pages": (total + per_page - 1) // per_page,
"status": status,
})
@app.post("/orders/{order_id}/status", response_class=HTMLResponse)
async def update_status(request: Request, order_id: int, new_status: str = Query(...)):
order = next((o for o in ORDERS if o["id"] == order_id), None)
if order:
order["status"] = new_status
return templates.TemplateResponse("partials/order_row.html", {
"request": request,
"order": order,
})
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Дашборд заказов</title>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
</head>
<body>
<h1>Заказы</h1>
<!-- Фильтр по статусу -->
<select hx-get="/orders/list"
hx-target="#orders-container"
hx-include="[name='status']"
name="status">
<option value="">Все</option>
<option value="new">Новые</option>
<option value="processing">В работе</option>
<option value="done">Выполнены</option>
</select>
<!-- Контейнер: загружается сразу при открытии страницы -->
<div id="orders-container"
hx-get="/orders/list"
hx-trigger="load">
</div>
</body>
</html>
<!-- templates/partials/orders_list.html -->
<table>
<thead>
<tr><th>ID</th><th>Заказ</th><th>Статус</th></tr>
</thead>
<tbody>
{% for order in orders %}
{% include "partials/order_row.html" %}
{% endfor %}
</tbody>
</table>
<!-- Пагинация -->
{% if page > 1 %}
<button hx-get="/orders/list?page={{ page - 1 }}&status={{ status }}"
hx-target="#orders-container">
← Назад
</button>
{% endif %}
{% if page < total_pages %}
<button hx-get="/orders/list?page={{ page + 1 }}&status={{ status }}"
hx-target="#orders-container">
Вперёд →
</button>
{% endif %}
Весь интерактив — 0 строк кастомного JavaScript. Сервер рендерит HTML-фрагменты, htmx вставляет их в DOM. Логика пагинации и фильтрации полностью на сервере, тестируется обычными unit-тестами.
htmx vs Turbo vs Livewire
Альтернативы существуют, и выбор зависит от стека:
Turbo (Hotwire) — часть Rails-экосистемы. Turbo Drive (перехватывает навигацию), Turbo Frames (частичные обновления), Turbo Streams (WebSocket/SSE). Мощнее из коробки, но заточен под Ruby on Rails — вне этого стека ощущается как чужеродное тело.
Livewire — для Laravel/PHP. Компонентная модель, двусторонняя привязка данных. По ощущениям ближе к Vue, но рендеринг на сервере. Хорош внутри PHP-стека, не применим за его пределами.
htmx — language-agnostic. Работает с любым сервером, который умеет отдавать HTML: Python, Go, Rust, Node, PHP. Минимальные соглашения, максимальная совместимость.
# Сравнение бандла (только клиентский JS):
# React + ReactDOM (production): ~140 KB gzip
# Vue 3 (runtime): ~34 KB gzip
# htmx 2.0: ~14 KB gzip
# Alpine.js (для мелкой реактивности): ~15 KB gzip
#
# htmx + Alpine.js вместе: ~29 KB — полноценный интерактив
# без build step, без node_modules, без webpack
Boosted links и прогрессивное улучшение
Атрибут hx-boost="true" на родительском элементе превращает обычные ссылки и формы в AJAX-запросы автоматически. Страница ведёт себя как SPA — без перезагрузки, с обновлением только <body> — но деградирует до обычного HTML при отключённом JS:
<!-- Все ссылки внутри nav становятся AJAX без изменения href -->
<nav hx-boost="true">
<a href="/dashboard">Дашборд</a>
<a href="/orders">Заказы</a>
<a href="/settings">Настройки</a>
</nav>
История браузера (pushState) обновляется автоматически. SEO-индексация работает — контент в HTML, не в JS.
Когда htmx не подходит
Честный список ограничений важнее маркетинга:
- Rich interactions: drag-and-drop, сложные canvas-интерфейсы, редакторы типа Figma или Google Docs — это React/Vue territory. htmx не заменяет JS для сложной клиентской логики.
- Offline-first приложения: htmx требует сетевого подключения для каждого взаимодействия. PWA со service workers и локальным кэшем — не его сценарий.
- Мобильные приложения: React Native, Flutter. htmx — только веб.
- Публичные API: если сторонние клиенты потребляют ваш API, JSON контракт неизбежен. htmx не отменяет необходимость в API — он добавляет HTML-эндпоинты рядом с JSON.
- Команда уже знает React: переучивание команды ради «меньше JS» — сомнительный ROI. htmx лучше всего работает там, где серверный шаблонизатор уже в стеке.
Хорошее правило: если UI-логика сложнее, чем «показать/скрыть, загрузить список, отправить форму» — добавьте Alpine.js для локального state. Если этого не хватает — рассмотрите частичное использование React для конкретных компонентов, оставив htmx для остального.
React решает проблемы, которые возникают после того, как ваш продукт вырос. htmx решает проблему прямо сейчас: меньше инструментов, меньше сложности, работающий продукт быстрее. Для большинства внутренних инструментов, дашбордов и CRUD-приложений архитектурный overhead SPA — это преждевременная оптимизация в обратную сторону.