🌳

バレルファイルで共通コンポーネントを作ったがimportが思った通りに動かなかった

2024/10/17に公開

はじめに

個人的なプロジェクトでは、モノレポ構成を採用することが多いです。そして共通コンポーネントをそれぞれのアプリケーションで使用する様にしています。

今回のプロジェクトではViteを使用してビルドし、shadcn/uiを利用してコンポーネントを作成しています。
また、1つのエントリーポイントでビルドして共通コンポーネントを作成し、それをそれぞれのアプリケーションで利用するという構成です。

/
├─ apps
│  ├─ applicationA // ここでButtonのみを呼び出す
│  └─ applicationB // ここで共通コンポーネントを色々呼び出す
├─ packages
│  └─ typescript
│     └─ ui // ここが共通コンポーネント
│        ├─ src
│        │  └─ components
│        │     └─ ui
│        │        ├─ button.tsx
│        │        ├─ calendar.tsx
│        │        ├─ select.tsx
│        │        └─ ...
│        ├─ package.json
│        └─ vite.config.ts
└─ ...

しかし、この方法には予期せぬ落とし穴があることが分かりました。
本記事では、バレルファイルを用いた共通コンポーネントの問題点と、それに代わる方法について説明します。

バレルファイルとは

バレルファイルは、複数のモジュールをまとめて一つのエントリーポイントとして提供するファイルです。通常、index.tsindex.jsという名前で作成され、以下のような形式になります:

export * from './src/components/button.tsx'
export * from './src/components/calendar.tsx'
export * from './src/components/select.tsx'
// ... その他のコンポーネント

この方法により、インポート文を簡潔にでき、コードの見通しが良くなると考えられています。

import { Button, Calendar } from 'path/to/index.ts'

問題の発生

applicationAをSSGでビルドする際に問題が発生しました。

このapplicationAではshadcn/uiで作成したButtonコンポーネントのみを使用しています。ButtonコンポーネントはServer Componentです。

このapplicationAをSSGビルドする際に以下の様なエラーが発生しました。

You're importing a component that needs useState. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.

つまりuseStateはクライアントのみで動くが、use clientがないためServerComponentになっているのでuse clientをつけてください。といった内容のエラーです

しかし、ButtonコンポーネントuseStateを使っていないしServerComponentで、SSGでビルドする際はuse clientなどをつけなくても問題ないはずです。なぜそのようなエラーが起こったのでしょうか?

原因はそうバレルファイルです。

バレルファイルを使用することで、Tree shakingが機能せず、共通コンポーネントの全てのコンポーネントがバンドルされてしまいます。その結果、useStateを使用しているコンポーネントも一緒に読み込まれ、エラーが発生していました。

Tree shakingとは、不要なコードを削除する機能のことで、この文脈ではインポートしていないコンポーネントをビルドに含めないようにする機能です。

Tree shakingがうまく機能していればButtonコンポーネントのみが読み込まれ、useStateを使用しているコンポーネントがビルドに含まれず、SSGで正常にビルドできる様になります。

解決方法

解決方法は単純でバレルファイルを使わないことです。具体的なpacakges/typescript/ui/vite.config.tsの設定は以下のようになります。

import glob from "fast-glob"
import { defineConfig, loadEnv } from "vite"

type Mode = "development" | "production" | "analyze"

// components配下のtsxファイルをエントリーポイントとして設定
const files = glob.sync(["./src/components/**/*.{ts,tsx}"]).map((file) => {
  const fileName = file.split("/").pop()?.split(".")[0]
  return [fileName, file]
})

const entries = Object.fromEntries(files)

export default ({ mode }: { mode: Mode }) => {
  // ... 省略 ...
  return defineConfig({
    build: {
      // ... その他の設定 ...
      lib: {
        entry: entries
      }
    },
    // ... その他の設定 ...
  })
}

上記の設定により、src配下の各コンポーネントが個別のファイルとしてdistディレクトリに出力されます。

/
├─ pacakges
│  └─ typescript
│     └─ ui
│        ├─ src
│        │  └─ components
│        │     └─ ui
│        │        ├─ button.tsx
│        │        ├─ calendar.tsx
│        │        ├─ select.tsx
│        │        └─ ...
│        ├─ dist // ビルド出力先のディレクトリ
│        │  └─ components
│        │     └─ ui
│        │        ├─ button.cjs
│        │        ├─ calendar.cjs
│        │        ├─ select.cjs
│        │        └─ ...
│        ├─ package.json
│        └─ vite.config.ts
└─ ...

これにより、import側では以下のように個別にコンポーネントをインポートできます

import { Button } from "shared/components/ui/button"
import { Select } from "shared/components/ui/select"

この変更後、applicationAを再度SSGでビルドしたところ、エラーが解消され、Tree shakingが正常に機能することが確認できました。

まとめ

バレルファイルは基本的には使わない方がよさそう。

おまけ

この記事で紹介したプロジェクトは、以下のサイトで公開しています。
広告なしでルーレットやグループ分けができるツールを提供しています。

ぜひ遊んでみてください🙏

https://game.flowzenn.com/

Discussion