8.4 KiB
8.4 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| frontend-htmx-hotwire | HTMX / Hotwire — Server-driven UI | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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 기본
<!-- 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
<!-- 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
<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
<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)
<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
<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)
<!-- Server response -->
<div id="primary">Updated</div>
<div id="notification" hx-swap-oob="true">Save successful!</div>
→ 한 응답 가 여러 곳 update.
Confirm
<button hx-delete="/users/42" hx-confirm="Are you sure?" hx-target="#user-42" hx-swap="delete">
Delete
</button>
Server (any backend)
// 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)
<button _="on click toggle .open on next div">Toggle</button>
<div>Content</div>
→ JS-like inline language. 작은 인터랙션.
Hotwire Turbo (Rails)
<!-- 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 -->
<!-- turbo-stream — push update -->
<turbo-stream action="append" target="messages">
<template>
<div>New message</div>
</template>
</turbo-stream>
Hotwire Stimulus
<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>
// 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)
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)
<div hx-ext="sse" sse-connect="/events" sse-swap="message">
Waiting for messages...
</div>
// 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
// 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)
<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.