はじめに
こんにちは!カイポケコネクトの開発推進チームでエンジニアをしている @_kimuson です。
私たちのチームでは、開発体験の向上や今後の拡張に備えて大規模なフロントエンドアプリケーションのマイクロフロントエンド化を進めています。 アプリ分割については下記の記事で紹介していますので、よろしければ合わせてご参照ください。
アプリ分割の一環としてpnpm workspaceを使ったモノレポ構成を採用しているのですが、internal packageにおけるpeerDependenciesの扱いが課題になりました。
この記事では、pnpm workspaceにおけるpeerDependenciesの問題とその解決策について整理してみます。同じような構成で開発している方の参考になれば幸いです。
pnpm workspace と peerDependencies、何が問題なのか
まずは問題の背景から説明します。
pnpm workspaceでは workspace:* プロトコルを使うことで、ローカルパッケージ間の依存をシンボリックリンクで解決してくれます。これがとても便利で、パッケージ内のファイルを編集すると即座に反映されますし、watchプロセスも不要なので開発環境が重くなりません。
典型的な構成はこんな感じです:
// apps/main/package.json { "dependencies": { "react": "^18.0.0", "shared-ui": "workspace:*" } } // packages/shared-ui/package.json { "peerDependencies": { "react": "^18.0.0" } }
apps/main が shared-ui に依存していて、shared-ui はReactをpeerDependenciesとして宣言しています。ごく普通の構成ですね。
シンボリックリンクが引き起こす問題
UI Library等ではよくある普通の構造だと思いますが、workspaceでのinternalパッケージでは致命的な問題があります。
シンボリックリンクでパッケージを参照すると、Node.jsのモジュール解決の仕組み上、それぞれのパッケージが 異なるライブラリの実態を参照してしまう、ということです。

シンボリックリンクにより shared-ui を参照していますが、それぞれが異なるreactインスタンスを参照してしまいます。
Node.jsはモジュールを解決するとき、呼び出し元のファイルから見て近いディレクトリから順番に node_modules を探していきます(参考: Node.js Documentation - Loading from node_modules folders)。shared-ui のソースコードから import React from 'react' すると、まず packages/shared-ui/node_modules/react を見つけてしまうわけです。
peerDependenciesは本来「利用側と同じインスタンスを使ってね」という意図で宣言するものですが、シンボリックリンクだと期待通りの解決になりません。
実際に問題が起きるケース
とはいえ、すべてのpeerDependenciesで問題が起きるわけではありません。問題が顕在化するのは、基本的にパッケージが グローバルな状態を持つ 場合です。
たとえば、わかりやすい例として単純なカウンターパッケージを考えてみます:
// counter.ts let count = 0; export const addCount = () => { count++; }; export const getCount = () => count;
このパッケージが shared-ui にpeerDependenciesとして追加されていると仮定して、shared-ui から addCount() を呼んでも、apps/main から getCount() を呼ぶと0が返ってきます。別のインスタンスの別の変数を見ているからですね。
Reactも同様で、useContext や useMemo などグローバルな状態に依存する機能を使うとエラーが発生します。一方、date-fnsやes-toolkitのような純粋な関数だけを提供するパッケージは、同じ実装が2箇所に存在するだけなので動作には問題ありません。
解決策
この問題に対するアプローチは大きく2つあります。いずれも peerDependencies を同じモジュールの実体に解決させる という方向性は同じです。
解決策1: バンドラーやテストフレームワークで依存解決をオーバーライドする
1つ目は、依存解決が行われる各ツールで、モジュールの解決先を上書きする方法です。
Jestの場合はこんな感じで設定します
// jest.config.ts import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const reactPath = require.resolve('react'); const jestConfig = { moduleNameMapper: { '^react$': reactPath, }, }; export default jestConfig;
webpackを使うNext.jsやStorybookでも、同様に resolve.alias で上書きできます。Viteでも dedupe オプションが提供されています。
メリット:
- シンボリックリンクの利点(即座に反映、watch不要、軽量)を維持できる
- 必要な箇所だけパッチを当てられる
デメリット:
- Next.js、Storybook、Jest/Vitestなど、依存解決を行うすべてのツールで設定が必要
- 新しいツールを追加するたびに設定を追加しないといけない
- 潜在的な問題は残る
- Ex. 実はグローバルな状態を持っていてロジックが壊れていることに後から気づく
- Ex. 同じコードが重複してバンドルに含まれてしまう
解決策2: pnpm の dependenciesMeta.*.injected を使う
2つ目は、pnpmが提供する injected オプションを使う方法です。
解決策1がパッチを充てるような対策だったことに対して、こちらは根本解決になります。
// apps/main/package.json { "dependencies": { "react": "^18.0.0", "shared-ui": "workspace:*" }, "dependenciesMeta": { "shared-ui": { "injected": true } } }
これを設定すると、pnpmは .pnpm ディレクトリ内に node_modules を持たないパッケージのクローン を作成します。
ディレクトリ構造は下記のようになります。
./ ├── apps │ └── main │ └── node_modules │ ├── .pnpm │ │ └── <shared-ui-clone> │ │ ├── src (shared-ui のコピー) │ │ └── node_modules (空) │ └── @my-pkg │ └── shared-ui --> .pnpm/<shared-ui-clone> ├── packages │ └── shared-ui └── package.json
apps/main からはこのクローンを参照するようになるので、shared-ui のコードからReactをimportしても、クローン側の node_modules は空であるため親ディレクトリをたどり、apps/main/node_modules/react へ解決されます。
つまり、apps/main のコードと shared-ui のコードが同じreactインスタンスを参照するようになります。
見ての通り根本的な解決であり、理想的に見えますが開発体験が致命的に悪いという問題を含んでいます。
injected による開発体験の劣化と融和策
injected を使うと、packages/shared-ui/src とそのクローンである apps/main/node_modules/.pnpm/<shared-ui-clone>/src は基本的にファイルごとにハードリンクで同期されます。
したがってファイルが追加されたり、削除されたりする場合には同期されません。
また、特定条件下でハードリンクではなく単なるコピーが行われるというUndocumentedな挙動があり、我々のプロジェクトではこの条件(shared-workspace-lockfile=false, postinstallあり)を満たすためコピーに寄って作成され、変更すら同期されないという状態でした。
起票したIssue:
https://github.com/pnpm/pnpm/issues/9828
この問題を補完するために、pnpm-sync-dependencies-meta-injected というツールがあります。これを使うと、開発中にソースコードの変更をwatchしてハードリンクを更新してくれるので、injectedを使いながらも快適な開発体験を維持できます。
ちなみにpnpm v10では syncInjectedDepsAfterScripts という公式オプションも追加されています。任意のスクリプト実行後にハードリンクを再同期してくれるもので、将来的にはこちらを使う選択肢もありそうです。
ただこれで全部解決だよね!ということでもなく「node_modulesを削除してpnpm iしなおさないと更新されない状態に定期的に陥る」といった問題が実際に発生しており、開発体験としては非常に悪い状態が残っています。
結局どちらを採用すればよいのか
少なくとも現在(2025年12月)ではどちらかが完璧な解決策にはなっておらず、トレードオフを考慮して選ぶ必要があります。
| 観点 | 解決策1(オーバーライド) | 解決策2(injected) |
|---|---|---|
| 開発体験 | ◎ | △ |
| 設定の煩雑さ・手間 | × | ◎ |
| 根本解決 | × | ◎ |
| バンドルサイズ | △(重複の可能性) | ◎(重複なし) |
可能であれば根本解決であるinjectedに寄せていきたい気持ちはありつつ、開発体験が悪すぎるので現状はオーバーライドに寄せています。
overrides 方式では解決できない問題もある
基本開発体験を優先してoverridesに寄せていますが、一部の共通パッケージではoverridesで解決できない問題があり、injectedも利用しています。
具体的には型解決の問題です。
型解決についてはpeerDependenciesの実態が異なるものに解決されていたとしてもバージョンさえ揃っていれば構造的部分型で型チェックされるため基本的には問題は起きません。
import { User } from 'peer-dep-pkg' import { getUser } from '@my-pkg/dep' const user: User /* node_modules/peer-dep-pkg */ = getUser() /* packages/dep/node_modules/peer-dep-pkg */
のように実態が異なっていても構造は同一であるため、型エラーにはなりえません。
弊プロダクトの場合はpnpm catalogを使って依存を管理しているため、バージョンは固定する運用をしているのでバージョン違いも起きません。
一方、TypeScriptでも「classを使っておりprivateプロパティを持つ場合」では例外的に総称型で型チェックされるケースが存在し、そういった型が使われているライブラリがpeerDependenciesに存在すると型チェックは適切に行えません。
// ライブライ側のコード例 class ApiClient { private cache: unknown; // ... }
import { ApiClient } from '@my-pkg/peerDep' import { createApiClient } from '@my-pkg/dep' const apiClient: ApiClient /* node_modules/peer-dep-pkg */ = createApiClient() /* packages/dep/node_modules/peer-dep-pkg */; // => 実態が異なるため総称型でチェックされ型エラー
この問題は解決のワークアラウンド的に回避も難しいので、一部のパッケージでは開発体験の劣化を許容してinjectedを採用しています。
終わりに
pnpm workspaceにおけるpeerDependenciesの問題と解決策について整理しました。
- シンボリックリンクによるモジュール解決の仕組み上、peerDependenciesが異なるインスタンスを参照してしまう問題がある
- グローバルな状態を持つパッケージ(Reactなど)や総称型になる型を公開するパッケージで問題が顕在化する
- 解決策としては「各ツールでオーバーライド」か「injectedオプション」の2つ
正直、overridesも設定が煩雑すぎるしワークアラウンドであること、injectedも体験が悪くてしっくりは来ていないのですが背景理解と方針検討にも苦労したので、同じようなworkspace化等に取り組んでいる方の参考になれば幸いです!
また、pnpmのリポジトリでのこの問題は議論されているので、injected(根本解決)ベースでより開発体験も維持できるソリューションが出てくると良いなと思っています。