以下の内容はhttps://ergofriend.hatenablog.com/entry/2026/02/21/170313より取得しました。


hono/jsxでもStorybookを使う

3行で

  • hono/jsx コンポーネントの動作確認にはサーバー起動が必要だった。
  • @storybook/html-viterenderToString で hono/jsx でも Storybook が使える。
  • コンポーネントをデータ取得から分離し、Props で受け取る形にするのがポイント。

hono/jsx 使ってますか?

サーバーサイドで JSX を使って UI を組めるのは便利ですよね。

Hono の JSX は軽量で書き味もよく、管理画面のような用途にはちょうどいい選択肢です。

hono.dev

しかし、コンポーネントの見た目を確認するには毎回サーバーを起動して、特定の状態を手動で再現する必要がありました。

エラー画面、検索結果が0件の場合、結果が複数件ある場合... ひとつずつ確認するのは大変です。

そこで Storybook を導入して、hono/jsx のコンポーネントをブラウザ上で確認できるようにしてみました。

hono/jsx と Storybook の相性問題

Storybook は React や Vue などのフレームワーク向けには公式対応がありますが、残念ながら hono/jsx にはないようです。

hono/jsx はサーバーサイド専用の JSX 実装なので、ブラウザ上で直接コンポーネントをマウントすることはできません。

ではどうするか? Storybook には @storybook/html というフレームワークがあります。

storybook.js.org

これは生の HTML 文字列を受け取って表示するだけのシンプルなフレームワークです。

hono/jsx には renderToString があるので、コンポーネントを HTML 文字列に変換してから Storybook に渡せばよいのです。

セットアップ

既に hono/jsx を使っているプロジェクトがあることを前提とします。

まずは必要なパッケージをインストールします。

pnpm add -D @storybook/html-vite storybook vite

@storybook/html@storybook/html-vite の依存に含まれているので、別途インストールする必要はありません。

package.jsonscripts に起動コマンドを追加しておきましょう。

{
  "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.renderrenderHtmlStory(SearchPageContent) を渡すだけで、あとは通常の Storybook と同じように args でデータを切り替えられます。

エラー状態、結果なし、結果ありなど、各状態をストーリーとして定義しておけばサーバーを起動しなくても一覧で確認できるようになりました。

pnpm storybook
# http://localhost:6006 で起動

いかがでしたか?

hono/jsx はサーバーサイド専用ですが、renderToString@storybook/html-vite を組み合わせることで Storybook 上でコンポーネントをプレビューできるようになりました。

やっていることは HTML 文字列への変換というシンプルなアプローチですが、各状態のビジュアル確認がサーバーなしで行えるようになるのは嬉しいですね。




以上の内容はhttps://ergofriend.hatenablog.com/entry/2026/02/21/170313より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14