T-CREATOR

Turborepo で Zustand スライスをパッケージ化:Monorepo 運用の初期設定

Turborepo で Zustand スライスをパッケージ化:Monorepo 運用の初期設定

複数のアプリケーションで状態管理のロジックを共有したいとき、どのようにコードを整理していますか?コピー&ペーストで同じコードを複数箇所に配置すると、メンテナンスが大変になりますよね。

Turborepo と Zustand を組み合わせることで、状態管理のロジックをパッケージとして整理し、複数のプロジェクト間で効率的に共有できます。この記事では、Zustand のスライスパターンを Turborepo で管理する初期設定方法を、実際のコード例とともに段階的に解説していきます。

モノレポ環境での状態管理に悩んでいる方、チーム開発でコードの再利用性を高めたい方にとって、きっと役立つ内容になるはずです。それでは、一緒に見ていきましょう。

背景

Monorepo における状態管理の課題

現代のフロントエンド開発では、複数のアプリケーションやサービスを 1 つのリポジトリで管理する Monorepo が広く採用されています。Web アプリケーション、管理画面、モバイルアプリなど、複数のプロジェクトが同じビジネスロジックを共有するケースが増えてきました。

しかし、状態管理のコードをどのように共有するかは悩ましい問題です。各プロジェクトで個別に実装すると、同じようなコードが重複してしまいます。また、仕様変更があった際には、すべてのプロジェクトを修正しなければなりません。

以下の図は、Monorepo における典型的な状態管理の課題を示しています。

mermaidflowchart TB
    repo["Monorepo<br/>リポジトリ"]

    subgraph apps["アプリケーション層"]
        web["Web アプリ"]
        admin["管理画面"]
        mobile["モバイルアプリ"]
    end

    subgraph states["状態管理コード(重複)"]
        state1["ユーザー状態<br/>(重複1)"]
        state2["ユーザー状態<br/>(重複2)"]
        state3["ユーザー状態<br/>(重複3)"]
    end

    repo --> apps
    web -.-> state1
    admin -.-> state2
    mobile -.-> state3

    style state1 fill:#ffcccc
    style state2 fill:#ffcccc
    style state3 fill:#ffcccc

図で理解できる要点:

  • 各アプリケーションが独自の状態管理コードを持つと重複が発生する
  • 仕様変更時には複数箇所の修正が必要になる
  • コードの一貫性を保つことが困難になる

Zustand スライスパターンとは

Zustand は軽量でシンプルな状態管理ライブラリです。その中でも「スライスパターン」は、状態を機能ごとに分割して管理する設計手法として注目されています。

スライスパターンでは、大きな Store を機能単位の小さなスライスに分割します。たとえば、ユーザー情報、商品情報、カート情報など、それぞれを独立したスライスとして定義するのです。

typescript// ユーザースライスの例
interface UserSlice {
  user: User | null;
  setUser: (user: User) => void;
  clearUser: () => void;
}
typescript// スライスを作成する関数
const createUserSlice: StateCreator<UserSlice> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null }),
});

このパターンの利点は、各スライスが独立しているため、再利用しやすく、テストもしやすくなることです。さらに、複数のスライスを組み合わせて 1 つの Store を構築できます。

typescript// 複数のスライスを組み合わせた Store
const useStore = create<UserSlice & ProductSlice>(
  (...args) => ({
    ...createUserSlice(...args),
    ...createProductSlice(...args),
  })
);

以下の図は、スライスパターンの構造を視覚化したものです。

mermaidflowchart LR
    subgraph store["Zustand Store"]
        direction TB
        userSlice["User Slice<br/>(ユーザー状態)"]
        productSlice["Product Slice<br/>(商品状態)"]
        cartSlice["Cart Slice<br/>(カート状態)"]
    end

    subgraph components["コンポーネント"]
        userComp["ユーザー<br/>プロフィール"]
        productComp["商品一覧"]
        cartComp["カート"]
    end

    userSlice --> userComp
    productSlice --> productComp
    cartSlice --> cartComp

    style userSlice fill:#e1f5ff
    style productSlice fill:#fff4e1
    style cartSlice fill:#ffe1f5

図で理解できる要点:

  • 各スライスは独立した機能単位で定義される
  • コンポーネントは必要なスライスのみを参照できる
  • スライス単位での再利用が容易になる

Turborepo の役割

Turborepo は、Monorepo を効率的に管理するためのビルドツールです。Vercel が開発しており、高速なビルドとキャッシュ機能が特徴となっています。

Turborepo の主な役割は以下の通りです。

#役割説明
1パッケージ管理ワークスペース内の複数パッケージを一元管理
2タスク実行最適化依存関係に基づいた並列実行とキャッシュ
3ビルドパイプライン効率的なビルドフローの構築
4開発体験向上高速なフィードバックループの実現

Turborepo では、turbo.json というファイルでビルドパイプラインを定義します。これにより、どのパッケージをどの順序でビルドするか、キャッシュをどう活用するかを細かく制御できるのです。

json{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

Zustand のスライスを Turborepo で管理することで、各スライスを独立したパッケージとして扱えます。変更があったスライスだけを再ビルドし、他のスライスはキャッシュを利用するため、ビルド時間を大幅に短縮できるでしょう。

課題

従来の状態管理パッケージ化における問題点

状態管理のコードをパッケージ化しようとすると、いくつかの問題に直面します。まず、すべての状態を 1 つの巨大なパッケージにまとめてしまうケースが多く見られました。

typescript// すべてを含む巨大なパッケージ(アンチパターン)
export const useAppStore = create((set) => ({
  // ユーザー関連
  user: null,
  setUser: (user) => set({ user }),

  // 商品関連
  products: [],
  setProducts: (products) => set({ products }),

  // カート関連
  cart: [],
  addToCart: (item) =>
    set((state) => ({ cart: [...state.cart, item] })),

  // 通知関連
  notifications: [],
  addNotification: (notification) =>
    set((state) => ({
      notifications: [...state.notifications, notification],
    })),

  // ... さらに多くの状態が続く
}));

このアプローチには以下のような問題があります。

#問題点影響
1パッケージサイズの肥大化必要のない機能まで含まれてしまう
2変更の影響範囲が大きい小さな修正でも全体を再ビルド
3テストの複雑化1 つの機能をテストするために多くの依存関係が必要
4チーム開発での競合同じファイルを複数人が編集しやすい

さらに、型定義の管理も課題となります。TypeScript を使用している場合、各状態の型を適切に定義し、共有する必要がありますが、巨大なパッケージでは型定義も複雑になってしまいます。

typescript// 複雑な型定義(メンテナンスが困難)
interface AppStore {
  user: User | null;
  setUser: (user: User) => void;
  products: Product[];
  setProducts: (products: Product[]) => void;
  cart: CartItem[];
  addToCart: (item: CartItem) => void;
  notifications: Notification[];
  addNotification: (notification: Notification) => void;
  // ... 型定義が続く
}

スライスの依存関係管理の難しさ

スライスを分割してパッケージ化する場合、依存関係の管理が新たな課題となります。あるスライスが別のスライスに依存しているとき、適切に管理しないと循環依存や不要な結合が発生してしまうのです。

以下の図は、不適切な依存関係の例を示しています。

mermaidflowchart TD
    userPkg["@repo/user-slice<br/>パッケージ"]
    cartPkg["@repo/cart-slice<br/>パッケージ"]
    productPkg["@repo/product-slice<br/>パッケージ"]

    userPkg -->|依存| cartPkg
    cartPkg -->|依存| productPkg
    productPkg -->|依存| userPkg

    style userPkg fill:#ffcccc
    style cartPkg fill:#ffcccc
    style productPkg fill:#ffcccc

    note["循環依存が発生<br/>ビルドエラーの原因に"]

    productPkg -.-> note

図で理解できる要点:

  • スライス間で循環依存が発生すると、ビルドが失敗する可能性がある
  • 依存関係が複雑になると、変更の影響範囲が予測しづらくなる
  • パッケージの独立性が損なわれる

具体的には、以下のような問題が発生します。

typescript// cart-slice.ts(カートスライス)
import { User } from '@repo/user-slice'; // ユーザースライスに依存

export interface CartSlice {
  items: CartItem[];
  user: User; // ユーザー情報を直接保持(結合度が高い)
  addItem: (item: CartItem) => void;
}
typescript// user-slice.ts(ユーザースライス)
import { CartItem } from '@repo/cart-slice'; // カートスライスに依存

export interface UserSlice {
  user: User | null;
  recentCartItems: CartItem[]; // カート情報を直接保持(循環依存)
  setUser: (user: User) => void;
}

このような循環依存を避けるためには、依存関係を一方向にする設計が重要です。しかし、実際のアプリケーション開発では、機能同士が密接に関わることが多く、適切な境界を引くのが難しいのです。

ビルド・キャッシュの最適化課題

Monorepo 環境では、ビルド時間の最適化が重要な課題となります。すべてのパッケージを毎回ビルドすると、開発のフィードバックループが遅くなり、生産性が低下してしまいます。

従来のビルドシステムでは、以下のような非効率が発生していました。

#非効率なパターン問題
1全パッケージの再ビルド変更のないパッケージまでビルドしてしまう
2キャッシュの不適切な管理キャッシュが効かず、同じ処理を繰り返す
3依存関係の不明確さ不要なパッケージまでビルド対象になる
4並列実行の不足順次実行により時間がかかる
bash# 従来のビルドコマンド(全パッケージを順次ビルド)
yarn workspace @repo/user-slice build
yarn workspace @repo/product-slice build
yarn workspace @repo/cart-slice build
yarn workspace @repo/notification-slice build
# ... すべてのスライスを順番にビルド

特に、スライスの数が増えるほど、この問題は深刻になります。10 個、20 個とスライスが増えていくと、ビルド時間が数分から数十分にまで膨れ上がることもあるのです。

さらに、CI/CD 環境では、毎回すべてをビルドすることになるため、デプロイまでの時間も長くなってしまいます。開発チームが大きくなり、複数の機能を並行して開発する場合、この問題はボトルネックとなるでしょう。

解決策

Turborepo でのパッケージ構成設計

Turborepo を活用することで、スライスを適切にパッケージ化し、効率的に管理できます。まず、推奨されるディレクトリ構成を見ていきましょう。

bashmy-monorepo/
├── apps/
│   ├── web/              # Web アプリケーション
│   ├── admin/            # 管理画面
│   └── mobile/           # モバイルアプリ
├── packages/
│   ├── store-user/       # ユーザースライス
│   ├── store-product/    # 商品スライス
│   ├── store-cart/       # カートスライス
│   └── ui/               # 共有 UI コンポーネント
├── package.json
├── turbo.json
└── yarn.lock

この構成では、各スライスを独立したパッケージとして packages​/​ ディレクトリに配置しています。アプリケーションは apps​/​ ディレクトリに格納し、必要なスライスパッケージを依存関係として指定します。

以下の図は、パッケージ間の依存関係を示しています。

mermaidflowchart TB
    subgraph apps["アプリケーション層"]
        web["Web アプリ"]
        admin["管理画面"]
    end

    subgraph packages["共有パッケージ層"]
        userSlice["@repo/store-user<br/>ユーザースライス"]
        productSlice["@repo/store-product<br/>商品スライス"]
        cartSlice["@repo/store-cart<br/>カートスライス"]
    end

    web --> userSlice
    web --> productSlice
    web --> cartSlice

    admin --> userSlice
    admin --> productSlice

    style web fill:#e1f5ff
    style admin fill:#e1f5ff
    style userSlice fill:#fff4e1
    style productSlice fill:#fff4e1
    style cartSlice fill:#fff4e1

図で理解できる要点:

  • アプリケーションは必要なスライスパッケージのみを依存関係に含める
  • スライスパッケージは独立しており、相互依存を避ける
  • 依存関係が一方向(アプリ → スライス)になっている

ルートの package.json では、ワークスペースを定義します。

json{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev"
  }
}

このように設定することで、Yarn Workspaces と Turborepo が連携し、効率的なパッケージ管理が可能になります。

Zustand スライスの分割戦略

スライスを適切に分割することで、再利用性と保守性が向上します。ここでは、効果的なスライス分割の戦略を紹介しましょう。

1. ドメイン単位での分割

ビジネスドメインごとにスライスを分けることで、責務が明確になります。

typescript// packages/store-user/src/index.ts
import { StateCreator } from 'zustand';

// ユーザー情報の型定義
export interface User {
  id: string;
  name: string;
  email: string;
}
typescript// ユーザースライスの型定義
export interface UserSlice {
  user: User | null;
  isLoading: boolean;
  setUser: (user: User) => void;
  clearUser: () => void;
  fetchUser: (id: string) => Promise<void>;
}
typescript// ユーザースライスの実装
export const createUserSlice: StateCreator<UserSlice> = (
  set
) => ({
  user: null,
  isLoading: false,

  setUser: (user) => set({ user, isLoading: false }),

  clearUser: () => set({ user: null }),

  fetchUser: async (id) => {
    set({ isLoading: true });
    try {
      // API 呼び出しの例
      const response = await fetch(`/api/users/${id}`);
      const user = await response.json();
      set({ user, isLoading: false });
    } catch (error) {
      set({ isLoading: false });
      throw error;
    }
  },
});

2. 状態とアクションのカプセル化

各スライスは、自身の状態と、その状態を変更するアクションをセットで提供します。

typescript// packages/store-cart/src/index.ts
import { StateCreator } from 'zustand';

// カートアイテムの型定義
export interface CartItem {
  productId: string;
  quantity: number;
  price: number;
}
typescript// カートスライスの型定義
export interface CartSlice {
  items: CartItem[];
  totalPrice: number;
  addItem: (item: CartItem) => void;
  removeItem: (productId: string) => void;
  clearCart: () => void;
}
typescript// カートスライスの実装
export const createCartSlice: StateCreator<CartSlice> = (
  set,
  get
) => ({
  items: [],
  totalPrice: 0,

  addItem: (item) =>
    set((state) => {
      const existingItem = state.items.find(
        (i) => i.productId === item.productId
      );

      if (existingItem) {
        // 既存アイテムの数量を更新
        const updatedItems = state.items.map((i) =>
          i.productId === item.productId
            ? { ...i, quantity: i.quantity + item.quantity }
            : i
        );
        return {
          items: updatedItems,
          totalPrice: calculateTotal(updatedItems),
        };
      } else {
        // 新規アイテムを追加
        const updatedItems = [...state.items, item];
        return {
          items: updatedItems,
          totalPrice: calculateTotal(updatedItems),
        };
      }
    }),

  removeItem: (productId) =>
    set((state) => {
      const updatedItems = state.items.filter(
        (i) => i.productId !== productId
      );
      return {
        items: updatedItems,
        totalPrice: calculateTotal(updatedItems),
      };
    }),

  clearCart: () => set({ items: [], totalPrice: 0 }),
});
typescript// ヘルパー関数
function calculateTotal(items: CartItem[]): number {
  return items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
}

3. スライスの粒度の決定

スライスは小さすぎても大きすぎても管理が難しくなります。以下の基準で粒度を決めるとよいでしょう。

#基準説明
1単一責任の原則1 つのスライスは 1 つのドメイン概念を扱う
2再利用可能性複数のアプリケーションで使える単位
3変更頻度同じタイミングで変更される機能をまとめる
4チーム構成チームの担当領域と一致させる

TypeScript 型定義の共有方法

スライスパッケージで定義した型を、他のパッケージやアプリケーションから利用できるようにする必要があります。ここでは、型定義の共有方法を説明します。

各スライスパッケージの package.json で、型定義ファイルのエントリーポイントを指定しましょう。

json{
  "name": "@repo/store-user",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  },
  "dependencies": {
    "zustand": "^4.0.0"
  }
}

TypeScript の設定ファイル tsconfig.json では、型定義の出力先を指定します。

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020"],
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

これにより、ビルド時に型定義ファイル(.d.ts)が自動生成され、他のパッケージから型情報を参照できるようになります。

アプリケーション側では、スライスパッケージをインポートするだけで、型情報も一緒に取得できます。

typescript// apps/web/src/store/index.ts
import { create } from 'zustand';
import {
  createUserSlice,
  UserSlice,
} from '@repo/store-user';
import {
  createCartSlice,
  CartSlice,
} from '@repo/store-cart';
typescript// 複数のスライスを組み合わせた Store
type StoreState = UserSlice & CartSlice;

export const useStore = create<StoreState>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

型定義が適切に共有されているため、IDE での自動補完やエラーチェックが効きます。これにより、開発体験が向上し、バグを未然に防げるでしょう。

具体例

プロジェクト初期設定

それでは、実際に Turborepo プロジェクトを作成していきましょう。まずは、新しいディレクトリを作成し、初期化します。

bash# プロジェクトディレクトリを作成
mkdir my-monorepo
cd my-monorepo
bash# package.json を作成
yarn init -y

package.json を編集して、Workspaces を設定します。

json{
  "name": "my-monorepo",
  "version": "1.0.0",
  "private": true,
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint"
  },
  "devDependencies": {
    "turbo": "^1.10.0",
    "typescript": "^5.0.0"
  }
}

次に、Turborepo の設定ファイル turbo.json を作成します。

json{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    }
  }
}

この設定により、以下のような最適化が実現されます。

#設定項目効果
1dependsOn: ["^build"]依存パッケージを先にビルドする
2outputsビルド成果物をキャッシュする
3cache: false(dev)開発時はキャッシュを使わない
4persistent: true開発サーバーを継続的に実行

ディレクトリ構造を作成します。

bash# アプリケーションディレクトリを作成
mkdir -p apps/web

# パッケージディレクトリを作成
mkdir -p packages/store-user
mkdir -p packages/store-cart

これで、基本的なプロジェクト構造が整いました。次に、具体的なスライスパッケージを実装していきましょう。

基本的なスライスパッケージの作成

ユーザースライスパッケージを作成します。まず、パッケージディレクトリに移動して初期化しましょう。

bash# ユーザースライスディレクトリに移動
cd packages/store-user
bash# package.json を作成
yarn init -y

packages​/​store-user​/​package.json を以下のように編集します。

json{
  "name": "@repo/store-user",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "dependencies": {
    "zustand": "^4.4.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "@types/node": "^20.0.0"
  }
}

TypeScript の設定ファイルを作成します。

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020"],
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "moduleResolution": "node",
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

次に、実際のスライスコードを実装します。src ディレクトリを作成しましょう。

bashmkdir src

packages​/​store-user​/​src​/​types.ts に型定義を記述します。

typescript// ユーザー情報の型定義
export interface User {
  id: string;
  name: string;
  email: string;
  avatarUrl?: string;
}
typescript// ユーザースライスの型定義
export interface UserSlice {
  user: User | null;
  isLoading: boolean;
  error: Error | null;
  setUser: (user: User) => void;
  clearUser: () => void;
  fetchUser: (id: string) => Promise<void>;
}

packages​/​store-user​/​src​/​slice.ts にスライスの実装を記述します。

typescriptimport { StateCreator } from 'zustand';
import { User, UserSlice } from './types';
typescript// ユーザースライスを作成する関数
export const createUserSlice: StateCreator<UserSlice> = (
  set
) => ({
  user: null,
  isLoading: false,
  error: null,

  // ユーザー情報を設定
  setUser: (user) =>
    set({
      user,
      isLoading: false,
      error: null,
    }),

  // ユーザー情報をクリア
  clearUser: () =>
    set({
      user: null,
      error: null,
    }),

  // API からユーザー情報を取得
  fetchUser: async (id) => {
    set({ isLoading: true, error: null });

    try {
      const response = await fetch(`/api/users/${id}`);

      if (!response.ok) {
        throw new Error(
          `Failed to fetch user: ${response.statusText}`
        );
      }

      const user: User = await response.json();
      set({ user, isLoading: false });
    } catch (error) {
      set({
        isLoading: false,
        error:
          error instanceof Error
            ? error
            : new Error('Unknown error'),
      });
    }
  },
});

packages​/​store-user​/​src​/​index.ts にエントリーポイントを作成します。

typescript// 型定義をエクスポート
export type { User, UserSlice } from './types';

// スライス作成関数をエクスポート
export { createUserSlice } from './slice';

これで、ユーザースライスパッケージの基本的な実装が完了しました。ビルドして動作を確認しましょう。

bash# パッケージをビルド
yarn build

ビルドが成功すると、dist​/​ ディレクトリに JavaScript ファイルと型定義ファイルが生成されます。

複数アプリケーションからのスライス利用

作成したスライスパッケージを、複数のアプリケーションから利用してみましょう。まず、Web アプリケーションを作成します。

bash# Web アプリディレクトリに移動
cd ../../apps/web
bash# package.json を作成
yarn init -y

apps​/​web​/​package.json を編集して、スライスパッケージを依存関係に追加します。

json{
  "name": "web",
  "version": "1.0.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^14.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "zustand": "^4.4.0",
    "@repo/store-user": "*",
    "@repo/store-cart": "*"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "@types/react": "^18.2.0",
    "@types/node": "^20.0.0"
  }
}

アプリケーション内で Store を作成します。apps​/​web​/​src​/​store​/​index.ts に以下のコードを記述しましょう。

typescriptimport { create } from 'zustand';
import {
  createUserSlice,
  UserSlice,
} from '@repo/store-user';
import {
  createCartSlice,
  CartSlice,
} from '@repo/store-cart';
typescript// 複数のスライスを組み合わせた Store の型定義
type StoreState = UserSlice & CartSlice;
typescript// Store を作成
export const useStore = create<StoreState>()((...args) => ({
  ...createUserSlice(...args),
  ...createCartSlice(...args),
}));

React コンポーネントから Store を利用します。apps​/​web​/​src​/​components​/​UserProfile.tsx を作成しましょう。

typescriptimport React, { useEffect } from 'react';
import { useStore } from '../store';
typescript// ユーザープロフィールコンポーネント
export const UserProfile: React.FC = () => {
  const { user, isLoading, error, fetchUser } = useStore();

  useEffect(() => {
    // コンポーネントマウント時にユーザー情報を取得
    fetchUser('user-123');
  }, [fetchUser]);

  if (isLoading) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return <div>エラー: {error.message}</div>;
  }

  if (!user) {
    return <div>ユーザーが見つかりません</div>;
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {user.avatarUrl && (
        <img src={user.avatarUrl} alt={user.name} />
      )}
    </div>
  );
};

同様に、管理画面アプリケーションでも同じスライスパッケージを利用できます。apps​/​admin​/​src​/​store​/​index.ts に以下のコードを記述します。

typescriptimport { create } from 'zustand';
import {
  createUserSlice,
  UserSlice,
} from '@repo/store-user';
typescript// 管理画面では User スライスのみ使用
type StoreState = UserSlice;

export const useStore = create<StoreState>()((...args) => ({
  ...createUserSlice(...args),
}));

このように、各アプリケーションは必要なスライスのみを選択して利用できます。スライスの実装は共通化されているため、ロジックの重複がなく、メンテナンスもしやすくなりました。

以下の図は、複数アプリケーションでのスライス利用を示しています。

mermaidflowchart TB
    subgraph apps["アプリケーション"]
        web["Web アプリ<br/>(全スライス利用)"]
        admin["管理画面<br/>(User のみ利用)"]
    end

    subgraph packages["スライスパッケージ"]
        userSlice["@repo/store-user"]
        cartSlice["@repo/store-cart"]
    end

    web --> userSlice
    web --> cartSlice
    admin --> userSlice

    style web fill:#e1f5ff
    style admin fill:#ffe1e1
    style userSlice fill:#fff4e1
    style cartSlice fill:#fff4e1

図で理解できる要点:

  • 各アプリケーションは必要なスライスのみを依存関係に含める
  • スライスパッケージは複数のアプリケーションで再利用される
  • ロジックの重複が排除され、一貫性が保たれる

ビルドパイプラインの構築

最後に、Turborepo を活用した効率的なビルドパイプラインを構築しましょう。プロジェクトルートの turbo.json を詳細に設定します。

json{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"],
      "env": ["NODE_ENV"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "outputs": []
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    }
  }
}

この設定により、以下のような動作が実現されます。

#タスク動作
1build依存パッケージを先にビルド、成果物をキャッシュ
2devキャッシュなしで開発サーバーを起動
3lintリント結果をキャッシュしない
4testビルド後にテストを実行、カバレッジをキャッシュ

ルートの package.json にスクリプトを追加します。

json{
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "clean": "turbo run clean && rm -rf node_modules"
  }
}

すべてのパッケージをビルドする際は、以下のコマンドを実行します。

bash# 全パッケージをビルド(並列実行 + キャッシュ活用)
yarn build

Turborepo は依存関係を解析し、最適な順序でビルドを実行します。変更のないパッケージはキャッシュから復元されるため、ビルド時間が大幅に短縮されるでしょう。

bash# 初回ビルド
• Packages in scope: @repo/store-user, @repo/store-cart, web, admin
• Running build in 4 packages
• Remote caching disabled

@repo/store-user:build: cache miss, executing
@repo/store-cart:build: cache miss, executing
web:build: cache miss, executing
admin:build: cache miss, executing

Tasks: 4 successful, 4 total
Time: 15.2s
bash# 2回目のビルド(変更なし)
• Packages in scope: @repo/store-user, @repo/store-cart, web, admin
• Running build in 4 packages

@repo/store-user:build: cache hit, replaying output
@repo/store-cart:build: cache hit, replaying output
web:build: cache hit, replaying output
admin:build: cache hit, replaying output

Tasks: 4 successful, 4 total
Cached: 4 cached, 4 total
Time: 0.8s >>> FULL TURBO

キャッシュが効いているため、2 回目のビルドは 1 秒以下で完了します。特定のパッケージのみを変更した場合も、そのパッケージと依存するアプリケーションのみが再ビルドされます。

開発時には、以下のコマンドですべての開発サーバーを同時に起動できます。

bash# 全アプリケーションの開発サーバーを起動
yarn dev

これにより、複数のアプリケーションを並行して開発でき、効率的な開発フローが実現されるのです。

まとめ

Turborepo と Zustand を組み合わせることで、Monorepo 環境における状態管理を効率的に運用できます。この記事では、スライスパターンを活用したパッケージ化の手法を、実際のコード例とともに解説しました。

重要なポイントをおさらいしましょう。

#ポイント内容
1スライス分割ドメイン単位で状態管理を分割し、再利用性を高める
2パッケージ構成各スライスを独立したパッケージとして管理する
3型定義共有TypeScript の型定義を適切にエクスポートする
4ビルド最適化Turborepo のキャッシュ機能で高速化を実現する

この手法を導入することで、複数のアプリケーション間でロジックを共有しながら、保守性の高いコードベースを維持できるでしょう。チーム開発においても、各スライスを独立して開発できるため、並行作業がスムーズになります。

さらに、ビルド時間の短縮により、開発のフィードバックループが高速化され、生産性の向上も期待できますね。まずは小さなスライスから始めて、徐々に拡張していくアプローチがおすすめです。

Monorepo での状態管理に悩んでいる方は、ぜひこの手法を試してみてください。きっと開発体験が向上するはずです。

関連リンク