HTMXでState管理をしないためのState管理。HTMX+Single Sourceアーキテクチャの紹介

Higtyの開発日記

HTMXのUI構築の仕組みと既存のState管理の仕組み

HTMXではReactのようなState管理はありません。サーバーにリクエストを送り、レスポンスのHTMLを指定した要素にセットする、というのが基本的な仕組みになります。


<button hx-trigger="click" hx-get="/View/TaskListTable" hx-target="#task-list-panel" hx-swap="innerHTML">Load</button>
<div id="task-list-panel"></div>

HTMXはReactなどと異なりブラウザのメモリ上で状態を持つ形ではありません。


Reactなどではタスク一覧画面ではStateにタスクの一覧を保持して

const [tasks, setTasks] = useState([
 { id: "001", title: "Task A", status: "In Progress" },
 { id: "002", title: "Task B", status: "Done" }
]);

レンダリングロジックでHTMLを描画します。

<tbody>
 {tasks.map(task => (
 <tr key={task.id}>
 <td>{task.id}</td>
 <td>{task.title}</td>
 <td>{task.status}</td>
 </tr>
 ))}
</tbody>

タスクのデータ自体はDBから取得することになるので

Server(DB)
 ↓
API
 ↓
React Query Cache
 ↓
Component State
 ↓
Render

という形で複数の個所でStateが存在するような形になります。当然これらの値の整合性を保つように同期管理が必要です。


DBをSingle Source of Truthにするアーキテクチャー

今回紹介するHTMXを利用したアーキテクチャーではDBを唯一の情報源(Single Source of Truth)とすることでState管理を劇的に簡単にします。


処理のフローは以下のようになります。


更新があった際にloadイベントで該当する部分のHTMLの最新をSWAPするというアーキテクチャーです。この方式のいいところはSignalRのWebSocketによるサーバー側からの通知でHTMXのloadイベントを呼び出すことで異なるタブ、異なるブラウザ、異なるマシンのタスクの表示も更新することができます。


app.MapPost("/api/task/edit", (context, hub) => {
    DB.Task_Edit(...);
    await hub.User("user-123").HtmxTriggerFromBody("task-001-load");
});

タスクの更新時にクライアントのロードイベントを実行します。HtmxTriggerFromBodyの内部ではSignalR経由でクライアントのメソッドを実行します。

this.connection.on("HtmxTriggerFromBody", this.htmxTriggerFromBody.bind(this));

private htmxTriggerFromBody(eventName: string) {
    const selector = "[hx-trigger*='" + eventName + " from:body']";
    this.htmxTrigger(selector, eventName);
}

というような感じでTypeScriptファイルを作成してサーバーからHTMXのイベントを起動できるようにするイメージです。


イベントの命名を一意に設計

イベントの名前を一意に設計すると非常に簡単に状態の同期ができます。タスクの状態が「実行中」に変更されDBのStatus=実行中になったとします。task-{taskId}-loadイベントを呼ぶことで該当のタスクのHTMLが全て最新のタスクの状態を元に更新されます。


この設計の面白いところは一覧形式のタスクとカード形式のタスクの両方を更新できることです。

//タスク一覧の1行
<tr class="task-row" hx-trigger="task-001-load" hx-post="/View/TaskTableRow" ...>
//かんばん上のタスクカード
<div class="task-card" hx-trigger="task-001-load" hx-post="/View/TaskCard" ...>

というように同じイベント名をつけてロードするエンドポイントを別にすることで両方とも更新できるというわけです。


また追加や削除時は一覧を丸ごと更新する形にすることで同期できます。

app.MapPost("/api/task/delete", (context, hub) => {
    DB.Task_Delete(...);
    await hub.User("user-123").HtmxTriggerFromBody("task-list-load");
});

他にオブジェクトが増えた場合でも同様な命名規則でschedule-001-loadやsupport-ticket-001-loadといった形で拡張でき、全体として非常に統一された形で記述でき、DBの最新の状態に完全に同期するように動作させることが可能です。


シンプルで効果的な同期設計

ReactなどではReact Query Cache と Local Stateで複数の個所に状態が散らばり、その管理が煩雑になりがちです。また異なるブラウザやマシンで同期をさせるとなるとより複雑性が増します。今回紹介したHTMX+Single Sourceアーキテクチャーにすることで面倒なState管理をせずに済みます。結果として見通しの良いソースコードでシステム全体を構築でき生産性が大幅に向上します。



In this article
HTMXのUI構築の仕組みと既存のState管理の仕組み
DBをSingle Source of Truthにするアーキテクチャー
イベントの命名を一意に設計
シンプルで効果的な同期設計