381 lines
8.4 KiB
Markdown
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]]
|