k8o

LLMS

Hono JSXで@storybook/react-viteと同じ使い心地を得るためのStorybookフレームワーク storybook-framework-hono-vite を作った

Hono JSXで書いたコンポーネントを@storybook/react-viteと同じ感覚でStorybookに組み込みたくて、storybook-framework-hono-viteを作りました。既存の@storybook/html-viteを流用する方式では毎storyにボイラープレートが必要で、interactionの検証やライフサイクル連動に限界があります。独自のStorybookフレームワークを作った背景と、提供しているものをまとめます。

公開: 2026年4月22日(水)
更新: 2026年4月22日(水)

はじめに

私は普段Reactで開発することが多く、コンポーネントのプレビューに加えてinteraction testやa11yテストまで一通り回せる開発体験を気に入っていて、Storybookをよく利用しています。Reactであれば@storybook/react-vite@storybook/nextjs-viteが揃っていて、Vitestと統合した開発もそのまま成立します。

最近はHonoを触る機会も増えてきました。Hono JSXである程度の規模のアプリを書こうとすると、Reactで使っていたのと同じようなStorybook環境をこちらでも整えたいという気持ちが出てきます。公式にHono向けのStorybookフレームワークは存在しないため、@storybook/html-viteを流用して凌いでいましたが、限界がありました。

そこで、Hono JSX向けのStorybookフレームワークstorybook-framework-hono-viteを作りました。hono/jsxhono/jsx/domのどちらのランタイムでも使えるようにしています。

この記事では、なぜ既存のStorybookフレームワークでは不十分だったのか、そして独自のStorybookフレームワークとして何を提供しているのかをまとめます。

既存のStorybookフレームワークで代替できるのか

Storybookはフレームワーク単位でrendererを切り替える設計になっています。Hono向けのフレームワークは存在しないので、既存のもので何とかできないかをまず検討しました。

@storybook/html-viteを流用する場合の限界

1つ目の候補は@storybook/html-viteです。もともとHono JSX向けではないので、storyのrender関数の中でHono JSXを文字列かHTMLElementに変換して返す必要があります。

前提として、Hono JSXには用途の異なる2つのランタイムがあります。hono/jsxはHTML文字列化やSuspenseストリーミングなどサーバーサイドレンダリングに寄ったランタイムで、hono/jsx/domはブラウザ上でDOMにマウントしてuseStateなどのフックを動かすクライアント寄りのランタイムです(どちらもReact互換のフックAPIを持ちます)。以降で示す2つのアプローチは、storyの戻り値としてHTML文字列を返す方式と、自前でマウントしたHTMLElementを返す方式です。

方式1: renderToStringでHTML文字列を返す

storyファイル側のJSXランタイムをhono/jsxに向けます(ファイル冒頭に/** @jsxImportSource hono/jsx */を置くか、tsconfig.jsonjsxImportSource: "hono/jsx"を指定)。そのうえでhono/jsx/dom/serverrenderToStringを使ってJSXをHTML文字列に変換し、storyの戻り値として返します。

import { renderToString } from 'hono/jsx/dom/server';
import { Button } from './button';

export const Primary = {
  render: (args) => renderToString(<Button {...args} />),
};

html-vite側は戻り値の文字列をinnerHTMLでcanvasに流し込むため、propsのバリエーション表示やCSSプレビュー、Docs pageの生成といった静的な範囲は問題なく動きます。

しかし、innerHTML注入の時点でuseStateやイベントハンドラは切り離されており、クリックしても状態が更新されません。初期DOMに対するa11yチェックは動きますが、interaction testや操作後の状態を含む検証は成立しません。

方式2: renderで自前のhost要素にマウントしてHTMLElementを返す

hono/jsx/domrenderを使って、自前で作ったhost要素にクライアント側のランタイムでマウントし、その要素をstoryの戻り値として返します。

import { render } from 'hono/jsx/dom';
import { Button } from './button';

export const Primary = {
  render: (args) => {
    const host = document.createElement('div');
    render(<Button {...args} />, host);
    return host;
  },
};

戻り値がHTMLElementの場合、html-viteは中身に触らずその要素をcanvasに差し込むだけなので、hono/jsx/domのランタイムが生きたまま保たれます。useStateもイベントハンドラも動き、単一storyのinteraction testであれば通せます。

ただしhono/jsx/domにはunmount APIが存在せず、rendervoidを返します。Storybookが古いhostをcanvasから外しても、hono/jsx/dom側はそれを検知できず、実行中のuseEffectのcleanupは呼ばれません。argsが変わるたびに新しいhostを作り直す方式では、古いhostに紐付いたeffectsが残留してしまい、useEffectでリスナやタイマーを貼るコンポーネントでは静かに漏れていきます。

さらにdecoratorと組み合わせると、storyが返すHTMLElementをdecorator側で受け取ってラップする必要が出てきます。html-viteのdecoratorはHTMLElement | stringを返す想定なので、Hono JSXで<div><Story /></div>のように宣言的に包むことができず、document.createElementで作ったwrapperにappendChildで差し込む、あるいはwrapper自体もHono JSXで別hostにマウントしてさらに入れ子にする、といった手続き的な組み立てが必要になります。decoratorの利点が薄れ、host要素の入れ子管理の手間が増えます。

どちらもstory側にボイラープレートが要る

どちらの方式でも、storyを書くたびにrenderToString(...)host+render(...)のラッパーをrenderプロパティに書く必要があります。@storybook/react-viteのようにcomponent: Counterと書くだけでマウントまで任せられる世界とは距離があり、しかも方式1は静的止まり、方式2はライフサイクル連動が不完全。Hono JSXをある程度の規模で扱うプロジェクトで、この手間とリスクを毎story引き受けるのは現実的ではありませんでした。

参考: @storybook/react-viteに混ぜる方式は成立しない

念のため@storybook/react-vitejsxImportSource: "hono/jsx"を指定する方式も検討しました。JSXのトランスパイル自体は通りますが、react-viteのrendererはReactDOM.createRoot(...).render()でReact要素として扱おうとするため、Honoのvnodeが渡った時点でObjects are not valid as a React childでクラッシュします。jsxImportSourceを差し替えるだけでは成立しないため、こちらの方向は早々に諦めました。

独自のStorybookフレームワークを作る

独自のStorybookフレームワークを作るにあたって目指したのは、@storybook/react-viteと同じ使い心地です。 React + @storybook/react-viteの環境で慣れ親しんだ書き方・挙動のまま、対象だけHono JSXに差し替えられる、という状態を目標にしました。

具体的には以下です。

  • Hono JSXで書いたコンポーネントをstoryの戻り値としてそのまま書ける
  • マウントはHono JSXのランタイムで行い、useStateなどのフックやイベントハンドラが実際に動く状態でプレビューできる
  • hono/jsxhono/jsx/domのどちらを使っているプロジェクトからでも利用できる
  • @storybook/addon-vitestと組み合わせて、@storybook/react-viteと同じ感覚でinteraction testを書ける
  • @storybook/addon-a11yによるアクセシビリティチェックもそのまま有効にする

Storybookのframework APIは、rendererとbuilderを差し替えられるように設計されています。 storybook-framework-hono-viteはViteをbuilderとして利用しつつ、rendererとしてHono JSX用のマウント処理を提供します。

使い方

.storybook/main.tsframeworkにこのパッケージを指定します。storiesaddonsはこれまで通りで、@storybook/react-viteを使っていたプロジェクトならframework.nameを差し替えるだけで済みます。

import type { StorybookConfig } from 'storybook-framework-hono-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  addons: ['@storybook/addon-vitest', '@storybook/addon-a11y'],
  framework: {
    name: 'storybook-framework-hono-vite',
    options: {},
  },
};

export default config;

storyの書き方も@storybook/react-viteと同じです。MetaStoryObjstorybook-framework-hono-viteからimportすれば、Hono JSXコンポーネントに対して型が効き、play関数の中でユーザー操作を記述できます。

import type { Meta, StoryObj } from 'storybook-framework-hono-vite';
import { Counter } from './counter';

const meta = {
  component: Counter,
} satisfies Meta<typeof Counter>;

export default meta;

export const Default: StoryObj<typeof meta> = {
  play: async ({ canvas, userEvent }) => {
    await userEvent.click(await canvas.findByRole('button'));
  },
};

decoratorもそのまま使えます。Decorator型をstorybook-framework-hono-viteからimportし、Hono JSXで包みたい要素を返す関数を定義して、metaやpreviewのdecoratorsに渡します。

import type { Decorator, Meta } from 'storybook-framework-hono-vite';
import { Counter } from './counter';

const withContainer: Decorator = (Story) => (
  <div style={{ padding: '1rem', maxWidth: '24rem' }}>
    <Story />
  </div>
);

const meta = {
  component: Counter,
  decorators: [withContainer],
} satisfies Meta<typeof Counter>;

interaction testとa11yチェックをVitest経由で回す場合は、vitest.config.ts@storybook/addon-vitestのプラグインを仕込みます。Vitestのbrowser modeでstoryを実際のブラウザ上に立ち上げ、play関数を実行する構成です。ここもReact系で書いていたVitest構成と差はありません。以下はPlaywrightのproviderを使う例です。

import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  plugins: [
    storybookTest({
      configDir: fileURLToPath(new URL('./.storybook', import.meta.url)),
      storybookScript: 'pnpm storybook --ci',
    }),
  ],
  test: {
    browser: {
      enabled: true,
      headless: true,
      provider: playwright(),
      instances: [
        {
          browser: 'chromium',
          context: {
            reducedMotion: 'reduce',
          },
        },
      ],
    },
  },
});

これで、Hono JSXで書いたコンポーネントを@storybook/react-viteと同じ感覚でStorybookで確認・テストできます。useStateを含むインタラクティブな挙動もそのまま動き、interaction testとa11yチェックをVitest経由で回せます。

おわりに

Hono JSXは、Reactとは別のランタイムを持ちながらReactライクな書き味でUIを書ける、実用的な選択肢です。StorybookはReactやVue、SvelteなどのUIフレームワークを幅広くサポートしていますが、Hono JSXに対応するStorybookフレームワークはこれまで用意されていませんでした。

@storybook/html-viteの流用では、storyごとにrenderToStringrender + host要素のボイラープレートが必要で、しかも前者はinteractionが動かず、後者はStorybookのライフサイクルとの連動に課題が残ります。Hono JSXのインタラクションまでStorybookで扱うには、Hono JSX向けのrenderer層を挟む必要がある、という結論になりました。

storybook-framework-hono-viteはその層を提供する小さなパッケージです。@storybook/react-viteと同じ感覚のまま、Hono JSXのプロジェクトにStorybookを組み込めます。Hono JSXでUIを書いている方は、ぜひ試してみてください。IssuePull Requestをお待ちしています。

0
もくじ