🔥

HonoX に Inertia の機構を取り入れてSPAっぽくしてみた

に公開

1. はじめに

以前の記事 で Hono × Inertia × React の組み合わせを書いた。型貫通と state 管理の楽さは最高だったが、React bundle と SPA client への重さも気になっていた。

その延長として、今度は「HonoX に Inertia の機構を取り入れる、ただし React は抜く」という実験を行ってみた。狙いを一行でまとめると:

サーバーが client の初期状態を作って送る。そこから先のインタラクティブは client が頑張る。

これを Hono ecosystem の純度を保ったまま薄く実現できるか、という試み。

動くものは既にデプロイしてある:

ログインすると Ping CRM 風の mini SaaS が動く。sidebar / Counter は遷移を跨いで生き続け、Dashboard では特定の prop だけが部分更新される —— こういう挙動を実機で確認できる。

※ 仮称 honox-frame。命名は未確定で、PoC が固まってから決める予定。


2. position — HonoX と Hono+Inertia の間

yusukebe さんの軌跡を辿ると、こうなっている:

Hono (server)

HonoX (server + lightweight islands、SPA nav なし)

Hono+Inertia (SPA nav あり、React/Vue を抱える)

ここで気付いた —— この2点の間が空いている

  • HonoX は MPA + islands で軽いが、SPA navigation が無い
  • Hono+Inertia は SPA navigation を提供してくれるが、React/Vue を抱える

HonoX に SPA navigation を載せる、ただし React/Vue は抱えない」という選択肢が Hono ecosystem 上に存在しなかった。これを埋めるのが honox-frame。

技術カテゴリで言うと "SPA っぽい MPA" 系統で、Rails の Hotwire / Astro view transitions に近い位置。

FW wire format client runtime 系統
Hotwire (Turbo) HTML Stimulus Rails
Inertia JSON React/Vue SPA
Hono+Inertia JSON React/Vue SPA on Hono
honox-frame HTML + JSON envelope + frame swap hono/jsx/dom (~5kb) HonoX 進化

Hotwire / Inertia を知ってる読者には「Hono 版のあれだな」で1秒で位置が伝わるはず。


3. なぜ React を抜いたのか

最初に強調しておくと、Hono+Inertia を否定する話ではない。Hono+Inertia は「React/Vue ecosystem に乗りたい人」に対する正解で、自分も別プロジェクトでは普通に使う。

ただ honox-frame で React を抜いたのには理由がある:

3.1 Hono ecosystem 純度

Hono は backend FW として bundle 軽さを重視している。そこに client runtime として React を抱えると、ecosystem の哲学と少しズレる。hono/jsx/dom は ~5kb で、HonoX が既に採用している runtime。これを SPA navigation 込みでも使い切る のが筋だと思った。

3.2 bundle size

  • React + ReactDOM: ~50kb (gzip)
  • hono/jsx/dom: ~5kb (gzip)

ざっくり10倍違う。Cloudflare Workers での edge SSR / SPA UX を考えると、無視できない差。

3.3 "Inertia の機構" は React/Vue 依存ではない

Inertia の核は wire format(envelope)と navigation の orchestration であって、render layer は分離可能。これを hono/jsx/dom に置き換えたらどうなるか、という検証がやりたかった。

つまり一文でまとめると:

HonoX × Inertia の機構 ÷ React


4. 動かしてみる — 見える 2 つの意義

show case(app/)は Ping CRM 風の mini SaaS。動かして見える本質は、ざっくり 2 つに集約できる

実装側で必要な primitive は <Frame> component ひとつだけ。これで swap 対象の領域を囲むと、外側は touch されず、内側だけが nav 時に差し替わる:

import { Frame } from "honox-frame/server";

export default function Layout({ children }) {
  return (
    <div class="flex">
      <Sidebar />                          {/* Frame 外 → 永続 */}
      <Frame id="main">{children}</Frame>  {/* Frame 内 → swap */}
    </div>
  );
}

中身はたった数行 —— <div data-honox-frame={id}> を出すだけの marker。client runtime はこの data-honox-frame 属性を頼りに swap 対象を見つける。

4.1 共通部分は再レンダリングされない

sidebar の nav を順に押すと、sidebar 自体は flash せず、main 領域だけが切り替わる。さらに sidebar の Counter が 5 のまま遷移しているのに注目 —— frame の外(sidebar 全体)は DOM ごと残り続けている

<div data-honox-frame="main"> で囲まれた要素の中身だけを client runtime が swap するため、外側の sidebar / Counter は touch されない。Hotwire の Turbo Frame + Inertia の persistent layout を組み合わせた挙動。

link 遷移だけでなく form submit 後の遷移 にも適用される(_method=DELETE 等の HTTP method override にも対応)。

裏側では X-Honox-Mode: json header をつけた fetch が1本飛び、server は { component, props, url } の envelope を返している(詳細は section 5)。

4.2 main の中も必要なところだけ差分更新できる

Dashboard で refresh stats を押すと、stats の更新時刻だけが更新され、それ以外は touch されない。注目は Dashboard 上に置いた Counter —— stats を refresh しても Counter は数字を保ったままで、リセットされない。

router.reload({ only: ["stats"] })

または HTML 上に <a href="..." data-honox-only="stats"> と書けば、X-Honox-Partial-Data: stats header が飛び、server は stats prop だけ resolve して返す。client は受け取った partial を既存の props にマージするので、それ以外の component state は触られない。Inertia v2 の partial reload 相当。


5. 設計の核 — envelope 切替と c.render overload

honox-frame の核は c.render の overload と envelope 切替

5.1 c.render overload

通常の HonoX では c.render(<Page />) は JSX を取って HTML を返す。honox-frame はこれに コンポーネント関数 + props の signature を加える:

app.get("/dashboard", (c) => {
  return c.render(DashboardPage, {
    user: c.var.user,
    stats: () => fetchStats(),  // 関数なら resolve 時に await
  });
});

関数値の props は lazy に解決される(Inertia v2 の deferred / lazy props 相当)。

5.2 envelope 切替

server は X-Honox-Mode header を見て HTML or JSON を返す:

Mode response 用途
html(default) full ページ HTML(<Page /> を render + page meta を <script> 埋め込み) 初回ロード、SEO、JS 無効時
json { component, props, url, partial, flash } JSON envelope SPA nav

client(navigate.ts)は SPA nav 時に常に X-Honox-Mode: json を送る。HTML response は fallback / 初回ロード用

補足: 元々 "HTML + JSON + Frame の 3-mode" として設計したが、実装上は server は 2-mode(HTML / JSON)に整理し、Frame 体験は client 側で HTML response から [data-honox-frame] を抽出する経路に統合した。

5.3 その他の API(要点だけ)

  • partial reload protocol: X-Honox-Partial-Data: stats,activity header で対象 prop を指定。server は該当 prop だけ resolve、client は既存 props にマージ
  • c.forward / c.back + flash messaging: redirect with flash を Hono context API として提供、payload は cookie 経由

いずれも Inertia 系の API を Hono の c.html / c.json / c.text の自由度と地続きに、薄く乗せた形。実装の詳細は第3弾の記事で深掘りする予定


6. 内部構造(軽く)

src/lib/ 以下、合計 500行強 で完結する。

ファイル 役割 行数
client/navigate.ts link/form intercept、fetch、frame swap、json handling、history 同期 ~290
client/app.tsx persistent App layer(hono/jsx/dom)、partial merge、flash dispatch ~87
server/render.ts withHonoxFrame middleware、c.render overload、c.forward / c.back ~140
server/Frame.tsx <Frame id="main"> marker ~5
server/flash.ts flash payload を cookie で運ぶ ~28

HonoX を fork してない

これが設計的に重要なポイント。HonoX を dependency として package.json に置き、app/client.ts で extension layer を起動するだけ:

// app/client.ts(最小パターン)
import { createClient } from "honox/client";
import { initNavigation, setPage } from "honox-frame/client";

createClient();                                       // HonoX の islands hydration
initNavigation({ onPageData: (d) => setPage(d) });    // honox-frame の SPA nav runtime

※ 実際の app/client.ts ではこの上に page meta 復元 / hydration coordination が乗っている。詳細は repo 参照。

HonoX 本体の進化が無料で乗ってくる。HonoX 側に hook が必要なら本体に PR する、という方向で考えられる。HonoX が fork されずに進化できることを示したい、という意図がある。


7. 達成と制約

show case で稼働している機能:

  • Frame swap(sidebar 維持の SPA navigation)
  • Islands re-hydrate(state 維持)
  • Form intercept(method override 含む)
  • HTML/JSON envelope 切替 + 受信側 frame swap
  • Partial reload(Inertia v2 風)
  • Lazy props(function 値を server で resolve)
  • Flash messaging(c.forward / c.back / Toast)

roadmap にあるが現状未実装または保留:

  • Streaming SSR — PoC route として一度実装したが、現バージョンの show case からは外して再検討中
  • 命名(仮称 honox-frame、未確定)
  • HonoX 本体への侵襲度の決め事(必要 hook を本体に逆 PR するか、extension layer 内で完結させ続けるか)
  • signal-as-cache 的な拡張の検討
  • ページ全体の client hydration — フロントFW の文脈で言うと このカテゴリの域、それ以上の領域には踏み込んでない

core vision との距離

正直に書いておくと、honox-frame の core vision —— サーバーが client の初期状態を作って送り、その先のインタラクティブは client が頑張る —— は、現状の show case で まだ半分くらいしか visible になっていない

frame swap / form intercept / partial reload は navigation 周りの可視機能で、これらは core vision の "サーバー作の初期状態を効率的に届ける" 部分。一方で、その先にあるべき "client が頑張る" 領域 —— client 側のページデータ cache、optimistic update、router.reload({ only: [...] }) を使った状態境界の意識的な設計 —— は、まだ show case に練り込めてない。

この vision の真価は、これらを使い切ったときに visible になる、という見立て。


8. まとめ

HonoX に Inertia の機構を取り入れる実験を続けてきた結果、2 つの意義(共通部分の preservation と 局所差分更新)が実機レベルで動く show case が用意できた段階。Hotwire/Inertia 系として実用できる粒度まで揃った形。

一行でまとめると:

HonoX × Inertia の機構 ÷ React

技術的に "世界初" の機能は無い。frame swap は Hotwire、partial reload + lazy props は Inertia v2、islands は Astro/Fresh/Marko に既存。新規性は組み合わせと文脈 にある —— Hono ecosystem における第一号の "SPA-like MPA" 選択肢で、HTML/JSON envelope と client frame swap を一つの薄い runtime にまとめてる点。

研究プロトタイプとして公開している:

フィードバックや意見は大歓迎です。GitHub Issues か X まで。

Discussion