📝

Next.js 環境で Storybook alternative の Ladle を試してみた

2024/12/09に公開

モチベーション

Storybook は大変素晴らしいツールで非常にお世話になっているが、v6 時代に依存関係で多少面倒だった覚えがある。[1]

そこで今後また Storybook 自体の更新の遅さが負債になるケースを想定し、代替ツールを調べてみて最初に見つけたのが Ladle だったので、これに移行できそうか調べてみた。

https://ladle.dev/

Ladle の依存関係については、React にしか対応しないという潔さもあってか Storybook よりはシンプルである。
webpack は使用しておらず、Vite を採用している。

Ladle を試してみる

私のユースケースで移行できるか確認したいので、環境は下記の通り。

  • Next.js: 14.2.20
  • Tailwind CSS: 3.4.16
  • Ladle: 4.1.2

※Ladle が React 19 に未対応のため、Next.js は v14 にしている。

私が Storybook に求めていたのは概ねコンポーネントの見た目を確認することだけなので、Ladle に関してもその点を満たせるかどうかに重点をおいて試した。
したがってすべての機能に触れているわけではない。

最終的なサンプルリポジトリは下記にあるので、詳しくコードを見たい場合は下記を参照してほしい。

https://gitlab.com/k1350/ladle-sample

セットアップ

セットアップ手順に従い Ladle をインストールすると、とりあえず Zero Configuration で動くことは動く。

しかし大抵の環境では設定ファイルが必要になってくるだろう。
たとえば Tailwind CSS 等を使っている場合、Ladle に CSS を読み込ませる必要がある。

また Next.js 対応を行う場合も幾らか設定ファイルが必要になる。

設定周りで Storybook より便利だと思ったのは MSW 対応である。
Ladle には MSW が内蔵されており、.ladle/config.mjs

/** @type {import('@ladle/react').UserConfig} */
export default {
  addons: {
    msw: {
      enabled: true,
    },
  },
};

と書くだけで MSW を使える。
MSW 自体のインストールや設定作業は不要であり、Storybook と比較して大変楽であった。

Story を書く

ドキュメントを眺めていると Component Story Format に対応していると書いてあり、もしかしたら Storybook の書き方ほとんどそのままで移行できたりするのか……? と思ったが、色々試した結果無理だった。(私の理解が足りないだけかもしれない。)

とりあえずは Ladle のドキュメントに書いてあるお作法で Story を書いてみる。
しかし公式ドキュメントが正直わかりづらい。
コンポーネントを別途定義し、そのコンポーネントを Story にインポートする使い方のほうが多いと思うのだが、公式ドキュメントは Story 内に直接コンポーネントが書いてあるような感じになっている。

また Controls にも対応しているものの、これもドキュメントがあまり直感的ではない。

ということで実際どう書くのが楽なのか、下記の記事を参考にした。

https://qiita.com/xrxoxcxox/items/040fbf98e840e6f4d8e6

たとえばボタンコンポーネントが下記のように実装されているとする。

import classNames from "classnames";

type Props = React.ComponentPropsWithoutRef<"button"> & {
  /** @default primary */
  variant?: "primary" | "secondary";
};

export const Button: React.FC<Props> = ({
  variant = "primary",
  className,
  ...props
}) => {
  return (
    <button
      className={classNames(
        "px-4 py-2 rounded",
        "data-[variant=primary]:bg-orange-500",
        "data-[variant=secondary]:border border-orange-500",
        className,
      )}
      {...props}
      data-variant={variant}
    />
  );
};

これの Story は以下のように書ける。

import type { Story, StoryDefault } from "@ladle/react";
import { Button } from "./index";

type ButtonProps = React.ComponentProps<typeof Button>;

export default {
  title: "Components/Button",
  args: {
    type: "button",
    children: "Button",
  },
  argTypes: {
    variant: {
      options: ["primary", "secondary"],
      control: { type: "radio" },
      defaultValue: "primary",
    },
  },
} satisfies StoryDefault<ButtonProps>;

const ButtonStory: Story<ButtonProps> = (props) => <Button {...props} />;

export const Primary = ButtonStory.bind({});

export const Secondary = ButtonStory.bind({});
Secondary.args = {
  variant: "secondary",
};

残念ながら TypeScript の型から Controls を自動で生成する機能はまだ存在しないが、実装する予定はあるそうである。

Next.js 対応(usePathname)

Storybook では parameters.nextjs.router を書き換えることで Next.js の usePathname 等の出力を上書きすることができる。

https://storybook.js.org/docs/get-started/frameworks/nextjs#overriding-defaults

Ladle にはこの機能が存在しない。
だが個人的にはこの機能は是非とも欲しい物である。

では移行を諦めなければいけないのか? というとそんなことはない。

Ladle の Next.js 対応では、useRouter でエラーを出さないために AppRouterContext の値を上書きしている。
それと同じようにすれば usePathname などの各種フックの返却値だって自在に操れるはずである。

というわけで手始めに usePathname を司る Context を探すと、下記に存在していた。

https://github.com/vercel/next.js/blob/ed78a4aa673034719d5664536a80d326eebac7e1/packages/next/src/shared/lib/hooks-client-context.shared-runtime.ts

PathnameContext.Provider の値を上書きしてやればよいらしい。

これは Story ごとの Decorator として実装しても可能であるが、せっかくなので Storybook のような使い心地にしたい。

GlobalProvider では globalStatestory metadata を使用できstory metadata を使えば各 Story から自由な値を GlobalProvider に渡すことができる。(厳密には "statically analyzable" でなければならないという制約がある。)

結論として、下記のような GlobalProvider を書けばよい。

.ladle/components.tsx
import type { GlobalProvider } from "@ladle/react";
import { AppRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { PathnameContext } from "next/dist/shared/lib/hooks-client-context.shared-runtime";

export const Provider: GlobalProvider = ({ children, ...props }) => {
  return (
    <AppRouterProvider {...props}>
      <PathnameProvider {...props}>{children}</PathnameProvider>
    </AppRouterProvider>
  );
};

const AppRouterProvider: GlobalProvider = ({ children }) => {
    // https://ladle.dev/docs/nextjs#nextnavigation と同じなので省略
  );
};

const PathnameProvider: GlobalProvider = ({ children, storyMeta }) => {
  return (
    <PathnameContext.Provider
      value={storyMeta?.nextjs?.router?.pathname || null}
    >
      {children}
    </PathnameContext.Provider>
  );
};

Story 側では下記のように meta.nextjs.router.pathname に文字列をセットすると、これが usePathname から返却される。

Pathname.stories.tsx
import type { StoryDefault } from "@ladle/react";

export default {
  meta: {
    nextjs: {
      router: {
        pathname: "/mocked-pathname",
      },
    },
  },
} satisfies StoryDefault;

他の各種フックについては試していないが、おそらく同様に解決できると思われる。

Storybook からの移行にあたり障害となりそうな点

  • Ladle は個人開発のようで、作者次第で開発が滞り得る。たとえば現在ナビゲーションがスクロールできないという問題が発生しており、それを解決する PR も存在するのだが、3週間ほど放置されている。
  • Ladle は現時点ではサードパーティー製の Addon を許可しておらず、Addon の数がかなり少ない。Storybook に存在する多種多様な Addon の挙動を再現しようと思うと難しい面もあると思う。
  • Addon 以外にも Storybook にあって Ladle には無い機能ももちろんある。中にはユーザーサイドの実装では如何ともしがたい物もありそう。

まとめ

簡単なサンプルコードを書きながら一通り機能をチェックした結果、自分で書かなければならない実装はありそうだが、私のユースケースでは概ね移行可能そうだった。
ただ TypeScript の型から Controls を自動で生成する機能が実装されてからのほうが移行は楽そうである。

最も懸念材料なのは Ladle 自体の持続可能性。
個人開発でサクッと使うには良さそうだが、業務で使うならやはりまだ Storybook のほうがいいかもしれない。

脚注
  1. 具体的に言うと v7 の開発が裏で進んでいる傍らで v6 は古い依存関係のまま置き去りにされ、webpack5 を使うためにプラグインの追加が必要だったり、バグを防ぐために特定のライブラリのバージョンを固定しなければならなかったりした。 ↩︎

chot Inc. tech blog

Discussion