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

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
frontend
htmx
hotwire
vibe-coding
language applicable_to
HTML / Server
Frontend
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 기본

<!-- 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> -->
<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.

🔗 관련 문서