3行で
- hono/jsx コンポーネントの動作確認にはサーバー起動が必要だった。
@storybook/html-viteとrenderToStringで hono/jsx でも Storybook が使える。- コンポーネントをデータ取得から分離し、Props で受け取る形にするのがポイント。
hono/jsx 使ってますか?
サーバーサイドで JSX を使って UI を組めるのは便利ですよね。
Hono の JSX は軽量で書き味もよく、管理画面のような用途にはちょうどいい選択肢です。
しかし、コンポーネントの見た目を確認するには毎回サーバーを起動して、特定の状態を手動で再現する必要がありました。
エラー画面、検索結果が0件の場合、結果が複数件ある場合... ひとつずつ確認するのは大変です。
そこで Storybook を導入して、hono/jsx のコンポーネントをブラウザ上で確認できるようにしてみました。
hono/jsx と Storybook の相性問題
Storybook は React や Vue などのフレームワーク向けには公式対応がありますが、残念ながら hono/jsx にはないようです。
hono/jsx はサーバーサイド専用の JSX 実装なので、ブラウザ上で直接コンポーネントをマウントすることはできません。
ではどうするか? Storybook には @storybook/html というフレームワークがあります。
これは生の HTML 文字列を受け取って表示するだけのシンプルなフレームワークです。
hono/jsx には renderToString があるので、コンポーネントを HTML 文字列に変換してから Storybook に渡せばよいのです。
セットアップ
既に hono/jsx を使っているプロジェクトがあることを前提とします。
まずは必要なパッケージをインストールします。
pnpm add -D @storybook/html-vite storybook vite
@storybook/html は @storybook/html-vite の依存に含まれているので、別途インストールする必要はありません。
package.json の scripts に起動コマンドを追加しておきましょう。
{ "scripts": { "storybook": "storybook dev -p 6006" } }
Storybook の設定
.storybook/main.ts を作成します。フレームワークには @storybook/html-vite を指定します。
import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { StorybookConfig } from "@storybook/html-vite"; const __dirname = dirname(fileURLToPath(import.meta.url)); const config: StorybookConfig = { stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], framework: "@storybook/html-vite", viteFinal: (config) => { config.resolve = config.resolve || {}; config.resolve.alias = { ...config.resolve.alias, "@": resolve(__dirname, "../src"), }; return config; }, }; export default config;
viteFinal でパスエイリアスを設定しておくと、ストーリーファイルからのインポートが楽になります。
CSS の読み込み
本番環境ではサーバーが配信する CSS を使っていますが、Storybook からはアクセスできません。
.storybook/preview-head.html に CDN から同等の CSS を読み込む設定を書いておきます。
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous" />
これで Storybook でも本番に近い見た目を再現できます。
なお、.storybook/preview.ts は今回の最小構成では不要です。必要に応じて追加してください。
hono/jsx を HTML に変換するユーティリティ
さて、ここからが本題です。hono/jsx コンポーネントを Storybook で表示するためのユーティリティ関数を用意します。
// src/lib/story-utils.tsx import type { StoryFn } from "@storybook/html"; import type { FC } from "hono/jsx"; import { renderToString } from "hono/jsx/dom/server"; export function renderHtmlStory<Params extends Record<string, unknown>>( Component: FC<Params> ): StoryFn<Params> { return (args) => renderToString(<Component {...args} />); }
Storybook の @storybook/html は render 関数が文字列を返せばそのまま表示してくれるので、これだけでうまくいきます。
コンポーネントの分離
Storybook で表示するには、コンポーネントをデータ取得ロジックから分離する必要があります。
もともとルートハンドラの中に直接 JSX を書いていた場合は、UI 部分を別ファイルに切り出します。
// Before: ルートハンドラに JSX が直書き export const searchPage = async (c: Context) => { const query = c.req.query("q"); const results = query ? await searchItems(query) : null; const error = /* ... */; return c.html( <Layout title="検索"> <h3>検索</h3> <form method="get" class="row g-2 align-items-center"> <div class="col-auto"> <input type="text" class="form-control" name="q" value={query || ""} /> </div> <div class="col-auto"> <button type="submit" class="btn btn-primary">検索</button> </div> </form> {error && <div class="alert alert-danger">{error.message}</div>} {results && results.length === 0 && ( <div class="alert alert-warning">結果が見つかりませんでした。</div> )} {results && results.length > 0 && ( <table class="table table-striped"> {/* テーブルの中身 */} </table> )} </Layout> ); };
データ取得と UI が混在していると、Storybook からは呼び出せません。
UI 部分を Props で受け取るコンポーネントとして切り出します。
// SearchPageContent.tsx export type SearchPageContentProps = { query?: string; results?: Result[]; error?: { code: string; message: string } | null; }; export const SearchPageContent = ({ query, results, error }: SearchPageContentProps) => { return ( <Layout title="検索"> <h3>検索</h3> <form method="get" class="row g-2 align-items-center"> <div class="col-auto"> <input type="text" class="form-control" name="q" value={query || ""} /> </div> <div class="col-auto"> <button type="submit" class="btn btn-primary">検索</button> </div> </form> {error && <div class="alert alert-danger">{error.message}</div>} {results && results.length === 0 && ( <div class="alert alert-warning">結果が見つかりませんでした。</div> )} {results && results.length > 0 && ( <table class="table table-striped">{/* テーブルの中身 */}</table> )} </Layout> ); };
ルートハンドラ側はデータを取得してコンポーネントに渡すだけになります。
// index.tsx import { SearchPageContent } from "./SearchPageContent"; export const searchPage = async (c: Context) => { const query = c.req.query("q"); const results = query ? await searchItems(query) : null; return c.html( <SearchPageContent query={query} results={results} />, ); };
こうしておけば Storybook からも自由にデータを渡せるようになります。
ストーリーを書く
あとは先ほどの renderHtmlStory を使ってストーリーを書いていくだけです。
import type { Meta, StoryObj } from "@storybook/html"; import { renderHtmlStory } from "@/lib/story-utils"; import { SearchPageContent, type SearchPageContentProps, } from "./SearchPageContent"; const meta = { title: "Pages/SearchPage", render: renderHtmlStory(SearchPageContent), parameters: { layout: "fullscreen", }, } satisfies Meta<SearchPageContentProps>; export default meta; type Story = StoryObj<SearchPageContentProps>; export const Default: Story = { args: {}, }; export const WithError: Story = { args: { query: "test", error: { code: "INVALID_QUERY", message: "指定されたクエリは無効です。", }, }, }; export const NoResults: Story = { args: { query: "test", results: [], }, }; export const WithResults: Story = { args: { query: "test", results: [ { id: "1", name: "アイテム1" }, { id: "2", name: "アイテム2" }, ], }, };
meta.render に renderHtmlStory(SearchPageContent) を渡すだけで、あとは通常の Storybook と同じように args でデータを切り替えられます。
エラー状態、結果なし、結果ありなど、各状態をストーリーとして定義しておけばサーバーを起動しなくても一覧で確認できるようになりました。
pnpm storybook
# http://localhost:6006 で起動
いかがでしたか?
hono/jsx はサーバーサイド専用ですが、renderToString と @storybook/html-vite を組み合わせることで Storybook 上でコンポーネントをプレビューできるようになりました。
やっていることは HTML 文字列への変換というシンプルなアプローチですが、各状態のビジュアル確認がサーバーなしで行えるようになるのは嬉しいですね。