Files
2nd/10_Wiki/Topics/Coding/Frontend_HTMX_Hotwire.md
T
2026-05-09 22:47:42 +09:00

381 lines
8.4 KiB
Markdown

---
id: frontend-htmx-hotwire
title: HTMX / Hotwire — Server-driven UI
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [frontend, htmx, hotwire, vibe-coding]
tech_stack: { language: "HTML / Server", applicable_to: ["Frontend"] }
applied_in: []
aliases: [HTMX, Hotwire, Turbo, Stimulus, server-driven UI, MPA renaissance, Phoenix LiveView]
---
# HTMX / Hotwire / Phoenix LiveView
> SPA 의 반발. **Server 가 HTML 보냄, JS 최소**. HTMX (any backend), Hotwire (Rails), Phoenix LiveView (Elixir). 작은 bundle + 빠른 dev.
## 📖 핵심 개념
- HTML over the wire: server → HTML fragment.
- AJAX without JS: HTMX attribute.
- Stateful server: WebSocket 으로 push.
- Less JS: 인터랙션 만 client.
## 💻 코드 패턴
### HTMX 기본
```html
<!-- Like button — server 가 새 HTML fragment -->
<button hx-post="/api/like" hx-target="#likes" hx-swap="innerHTML">
Like
</button>
<span id="likes">42</span>
<!-- Server -->
<!-- POST /api/like -->
<!-- Response: <span id="likes">43</span> -->
```
→ JS 0. Server 가 HTML 반환 → DOM swap.
### Triggers
```html
<!-- Click (default) -->
<button hx-get="/load">Load</button>
<!-- Input -->
<input hx-post="/search" hx-trigger="keyup changed delay:500ms" hx-target="#results" />
<!-- Visible -->
<div hx-get="/load-more" hx-trigger="revealed">Loading...</div>
<!-- Every X seconds -->
<div hx-get="/status" hx-trigger="every 5s">...</div>
<!-- On load -->
<div hx-get="/init" hx-trigger="load"></div>
```
### Swap modes
```html
<button hx-post="/data" hx-swap="innerHTML"> <!-- default -->
<button hx-post="/data" hx-swap="outerHTML"> <!-- 자기 element 교체 -->
<button hx-post="/data" hx-swap="afterend"> <!-- after 추가 -->
<button hx-post="/data" hx-swap="beforeend"> <!-- 안 끝에 추가 -->
<button hx-post="/data" hx-swap="delete"> <!-- element 제거 -->
```
### Form
```html
<form hx-post="/users" hx-target="#user-list" hx-swap="afterbegin">
<input name="email" type="email" required>
<input name="name" required>
<button>Add</button>
</form>
<ul id="user-list">
<!-- 새 user 가 위에 추가 -->
</ul>
<!-- Server -->
<!-- POST /users → Response: <li>Alice (a@b.com)</li> -->
```
### Boost (link / form 자동 AJAX)
```html
<body hx-boost="true">
<a href="/about">About</a> <!-- AJAX, no full reload -->
<form action="/login" method="post"> <!-- AJAX -->
...
</form>
</body>
```
→ MPA 처럼 link / form. SPA-like UX.
### Indicator
```html
<button hx-post="/save" hx-indicator="#saving">Save</button>
<span id="saving" class="htmx-indicator">Saving...</span>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
</style>
```
→ Loading state 자동.
### OOB swap (다른 곳도 update)
```html
<!-- Server response -->
<div id="primary">Updated</div>
<div id="notification" hx-swap-oob="true">Save successful!</div>
```
→ 한 응답 가 여러 곳 update.
### Confirm
```html
<button hx-delete="/users/42" hx-confirm="Are you sure?" hx-target="#user-42" hx-swap="delete">
Delete
</button>
```
### Server (any backend)
```ts
// Hono
app.post('/like', async (c) => {
const postId = c.req.param('postId');
const newCount = await incrementLikes(postId);
return c.html(`<span id="likes">${newCount}</span>`);
});
app.post('/users', async (c) => {
const formData = await c.req.formData();
const user = await createUser(Object.fromEntries(formData));
return c.html(`<li>${user.name} (${user.email})</li>`);
});
```
→ HTML fragment 반환.
### Hyperscript (HTMX 의 sister)
```html
<button _="on click toggle .open on next div">Toggle</button>
<div>Content</div>
```
→ JS-like inline language. 작은 인터랙션.
### Hotwire Turbo (Rails)
```html
<!-- turbo-frame — 부분 page reload -->
<turbo-frame id="user_list">
<ul>
<li>Alice</li>
<li>Bob</li>
</ul>
</turbo-frame>
<a href="/users/new" data-turbo-frame="user_list">Add</a>
<!-- → 이 link 가 frame 만 reload -->
```
```html
<!-- turbo-stream — push update -->
<turbo-stream action="append" target="messages">
<template>
<div>New message</div>
</template>
</turbo-stream>
```
### Hotwire Stimulus
```html
<div data-controller="hello">
<input data-hello-target="name" type="text">
<button data-action="click->hello#greet">Greet</button>
<span data-hello-target="output"></span>
</div>
```
```js
// hello_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['name', 'output'];
greet() {
this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`;
}
}
```
### Phoenix LiveView (Elixir)
```elixir
defmodule MyAppWeb.UserLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, users: list_users(), query: "")}
end
def handle_event("search", %{"q" => q}, socket) do
users = search_users(q)
{:noreply, assign(socket, users: users, query: q)}
end
def render(assigns) do
~H"""
<input type="text" phx-keyup="search" phx-debounce="300" value={@query} />
<ul>
<%= for user <- @users do %>
<li><%= user.name %></li>
<% end %>
</ul>
"""
end
end
```
→ WebSocket + diff push. SPA UX + server logic.
### Use cases
```
HTMX:
✅ CRUD apps
✅ Admin dashboards
✅ Forms / wizards
✅ E-commerce (product list)
✅ Real-time updates (polling)
❌ Heavy interactive (game, drawing)
❌ Offline-first
❌ Mobile native
```
### When 가치
```
- Backend team 가 frontend 도 — JS 적게
- 빠른 dev cycle
- 작은 / medium app
- SEO critical
- Mobile slow network
- Server-side state important
```
### When NOT 가치
```
- Heavy client interaction (drag, drawing)
- Offline app
- Mobile native (RN / Flutter)
- 큰 / 복잡 UI state
```
### Bundle
```
HTMX: ~14 KB (gzip)
Hotwire: ~50 KB
React: ~45 KB
+ app code
→ HTMX = 가장 작음.
```
### Performance
```
HTMX:
- Server-side render — fast TTFB
- 작은 JS — 빠른 hydration X (no hydration)
- AJAX = 작은 response
→ Marketing site / blog / CRUD = 매우 빠름.
```
### Real-time (HTMX SSE)
```html
<div hx-ext="sse" sse-connect="/events" sse-swap="message">
Waiting for messages...
</div>
```
```ts
// Server
app.get('/events', (c) => {
return new Response(
new ReadableStream({
start(controller) {
const send = (data: any) => {
controller.enqueue(`data: <div>${data}</div>\n\n`);
};
// ...
},
}),
{ headers: { 'Content-Type': 'text/event-stream' } }
);
});
```
### React + HTMX hybrid
```
일부 page = HTMX (form, CRUD).
일부 page = React (interactive).
같은 app.
```
### Test
```ts
// Playwright
test('like button increments', async ({ page }) => {
await page.goto('/post/1');
const button = page.getByText('Like');
const counter = page.locator('#likes');
await expect(counter).toHaveText('42');
await button.click();
await expect(counter).toHaveText('43');
});
```
→ E2E 가 자연 (HTML server).
### Pitfalls
```
1. Backend = template (Handlebars / EJS / 자체).
2. CSRF token 매 form.
3. Validation = server-side.
4. URL state — 명시적 hx-push-url.
5. Browser back button — 자동 X. Configure.
```
### Datastar (modern alternative)
```html
<div data-on-load="$count = 0">
<button data-on-click="$count++">{{$count}}</button>
</div>
```
→ HTMX 의 modern 후속. SignalR-like reactivity.
### Build / deploy
```
Backend = template + routes.
Frontend = HTML + 작은 JS (HTMX).
Deploy = 일반 server.
→ Vercel / Netlify (static) X — server 필요.
```
## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| CRUD admin | HTMX |
| Rails app | Hotwire (built-in) |
| Phoenix / Elixir | LiveView |
| 작은 인터랙션 | HTMX + Stimulus / Hyperscript |
| Heavy SPA | React / Solid |
| Backend-heavy team | HTMX |
## ❌ 안티패턴
- **HTMX + 큰 client state**: 잘못된 선택. SPA.
- **Server template 없음**: HTML fragment 어떻게?
- **CSRF 없음**: form 위험.
- **모든 page 가 sse-connect**: server 부담.
- **Validation client only**: server 가 진실.
- **JS 부족 — 사용자 못 input**: progressive 검토.
## 🤖 LLM 활용 힌트
- HTMX = MPA renaissance.
- Server-side template + HTML fragment.
- Boost = SPA-like links / forms.
- 작은 / CRUD app 의 sweet spot.
## 🔗 관련 문서
- [[Frontend_Progressive_Enhancement]]
- [[Backend_Server_Components_Pattern]]
- [[Backend_Hono_Modern]]