T-CREATOR

フィーチャーフラグ運用:Zustand で段階的リリースとリモート設定を実装

フィーチャーフラグ運用:Zustand で段階的リリースとリモート設定を実装

フィーチャーフラグ(Feature Flag)は、新機能を段階的にリリースしたり、特定のユーザーグループに対してのみ機能を有効化したりするための強力な手法です。本記事では、軽量な状態管理ライブラリである Zustand を活用し、リモート設定と連携したフィーチャーフラグシステムを実装する方法を詳しく解説します。

実際のプロダクション環境で利用できる実装パターンを、段階的に学んでいきましょう。

背景

フィーチャーフラグとは

フィーチャーフラグは、コードをデプロイせずに機能の ON/OFF を切り替える仕組みです。これにより、以下のようなメリットが得られます。

  • 段階的リリース: 一部のユーザーにのみ新機能を提供し、段階的に展開できます
  • A/B テスト: 異なる機能バージョンを比較検証できます
  • 緊急停止: 問題が発生した機能を即座に無効化できます
  • 環境分離: 開発中の機能を本番環境にデプロイしつつ、非表示にできます

以下の図は、フィーチャーフラグを活用した段階的リリースのフローを示しています。

mermaidflowchart TB
  deploy["新機能を<br/>デプロイ"] -->|フラグ OFF| hidden["全ユーザーに<br/>非表示"]
  hidden -->|フラグ ON| beta["ベータユーザーに<br/>公開"]
  beta -->|検証 OK| partial["一部ユーザーに<br/>段階公開"]
  partial -->|問題なし| all["全ユーザーに<br/>公開"]
  partial -->|問題発生| emergency["緊急停止<br/>フラグ OFF"]
  emergency --> fix["修正"]
  fix --> beta

この図からわかるように、フィーチャーフラグを使えば、デプロイと機能公開を分離でき、リスクを最小限に抑えながら新機能をリリースできます。

Zustand を選ぶ理由

Zustand は Redux や Recoil と比較して、以下の特徴を持つ軽量な状態管理ライブラリです。

#項目ZustandReduxRecoil
1バンドルサイズ約 1KB約 13KB約 18KB
2ボイラープレート最小限多い中程度
3TypeScript サポート★★★★★★★
4学習コスト低い高い中程度
5React 外での利用可能可能不可

フィーチャーフラグのような比較的シンプルな状態管理には、Zustand の軽量さとシンプルさが最適です。

課題

フィーチャーフラグ実装における一般的な課題

フィーチャーフラグを実装する際、以下のような課題に直面することが多いです。

1. 状態管理の複雑化

フィーチャーフラグの数が増えると、どこで何が有効化されているか把握しづらくなります。また、フラグの状態を複数のコンポーネント間で同期する必要があります。

2. リモート設定との連携

サーバー側でフラグを管理し、クライアント側で動的に反映させる仕組みが必要です。さらに、リモート設定の取得失敗時のフォールバック処理も考慮しなければなりません。

3. パフォーマンスへの影響

フラグの状態変更が不必要な再レンダリングを引き起こし、アプリケーションのパフォーマンスに悪影響を与える可能性があります。

4. ユーザーセグメント対応

特定のユーザーグループ(ベータテスター、有料会員など)に対してのみ機能を有効化する仕組みが必要です。

以下の図は、これらの課題がどのように関連しているかを示しています。

mermaidflowchart LR
  remote["リモート設定<br/>サーバー"] -->|設定取得| client["クライアント<br/>アプリ"]
  client -->|フラグ状態| comp1["コンポーネント A"]
  client -->|フラグ状態| comp2["コンポーネント B"]
  client -->|フラグ状態| comp3["コンポーネント C"]
  comp1 -.->|再レンダリング| perf["パフォーマンス<br/>課題"]
  comp2 -.->|再レンダリング| perf
  comp3 -.->|再レンダリング| perf
  remote -.->|取得失敗| fallback["フォールバック<br/>処理"]

これらの課題を解決するために、Zustand の機能を最大限に活用していきます。

解決策

Zustand を活用したフィーチャーフラグアーキテクチャ

Zustand を使ってフィーチャーフラグシステムを構築すると、以下のような構成になります。

mermaidflowchart TB
  subgraph Server["サーバー側"]
    api["Feature Flag API"]
    db[("フラグ設定<br/>データベース")]
    api --> db
  end

  subgraph Client["クライアント側"]
    store["Zustand Store<br/>フラグ状態管理"]
    hooks["Custom Hooks<br/>useFeatureFlag"]
    components["React<br/>コンポーネント"]

    store --> hooks
    hooks --> components
  end

  api -->|設定取得| store
  store -->|更新通知| components

この構成により、サーバー側でフラグを一元管理しつつ、クライアント側では Zustand で効率的に状態を管理できます。

実装方針

以下の 3 ステップで実装を進めます。

  1. 基本的なフィーチャーフラグストアの作成: Zustand でフラグの状態を管理
  2. リモート設定との連携: API からフラグ設定を取得し、ストアに反映
  3. ユーザーセグメント対応: ユーザー属性に基づいてフラグを動的に制御

それぞれのステップを詳しく見ていきましょう。

具体例

ステップ 1: 基本的なフィーチャーフラグストアの作成

まず、Zustand をインストールします。

bashyarn add zustand

型定義の作成

フィーチャーフラグの型を定義します。これにより、TypeScript の型安全性を確保できます。

typescript// types/featureFlags.ts

/**
 * フィーチャーフラグのキー
 * 新しいフラグを追加する場合は、ここに追加する
 */
export type FeatureFlagKey =
  | 'newDashboard'
  | 'darkMode'
  | 'experimentalSearch'
  | 'premiumFeatures'
  | 'betaAnalytics';

/**
 * フィーチャーフラグの設定
 */
export interface FeatureFlag {
  key: FeatureFlagKey;
  enabled: boolean;
  description?: string;
  // フラグが有効化されるユーザーセグメント
  segments?: string[];
}

型定義により、フラグのキー名をハードコーディングせず、型安全にアクセスできるようになります。

Zustand ストアの実装

次に、フィーチャーフラグを管理する Zustand ストアを作成します。

typescript// store/featureFlagStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import type {
  FeatureFlagKey,
  FeatureFlag,
} from '@/types/featureFlags';

/**
 * フィーチャーフラグストアの状態
 */
interface FeatureFlagState {
  // フラグの状態を保持するマップ
  flags: Record<FeatureFlagKey, boolean>;
  // すべてのフラグ設定情報
  flagConfigs: FeatureFlag[];
  // ロード状態
  isLoading: boolean;
  // エラー状態
  error: string | null;
}

上記のコードでは、フラグの ON/OFF 状態(flags)、詳細な設定情報(flagConfigs)、ロード状態を管理します。

typescript/**
 * フィーチャーフラグストアのアクション
 */
interface FeatureFlagActions {
  // 特定のフラグを取得
  getFlag: (key: FeatureFlagKey) => boolean;
  // 特定のフラグを設定
  setFlag: (key: FeatureFlagKey, enabled: boolean) => void;
  // 複数のフラグを一括設定
  setFlags: (
    flags: Record<FeatureFlagKey, boolean>
  ) => void;
  // フラグ設定をクリア
  clearFlags: () => void;
  // ロード状態を設定
  setLoading: (isLoading: boolean) => void;
  // エラー状態を設定
  setError: (error: string | null) => void;
}

アクションを別のインターフェースとして定義することで、状態とアクションを明確に分離できます。

typescripttype FeatureFlagStore = FeatureFlagState &
  FeatureFlagActions;

/**
 * デフォルトのフラグ設定
 * リモート設定が取得できない場合のフォールバック
 */
const defaultFlags: Record<FeatureFlagKey, boolean> = {
  newDashboard: false,
  darkMode: true,
  experimentalSearch: false,
  premiumFeatures: false,
  betaAnalytics: false,
};

デフォルト設定により、リモート設定の取得に失敗してもアプリケーションが動作し続けます。

typescript/**
 * フィーチャーフラグストアの作成
 */
export const useFeatureFlagStore =
  create<FeatureFlagStore>()(
    devtools(
      persist(
        (set, get) => ({
          // 初期状態
          flags: defaultFlags,
          flagConfigs: [],
          isLoading: false,
          error: null,

          // フラグ取得
          getFlag: (key) => {
            return get().flags[key] ?? false;
          },

          // フラグ設定
          setFlag: (key, enabled) => {
            set((state) => ({
              flags: {
                ...state.flags,
                [key]: enabled,
              },
            }));
          },

          // 複数フラグ一括設定
          setFlags: (flags) => {
            set({ flags });
          },

          // フラグクリア
          clearFlags: () => {
            set({ flags: defaultFlags });
          },

          // ロード状態設定
          setLoading: (isLoading) => {
            set({ isLoading });
          },

          // エラー状態設定
          setError: (error) => {
            set({ error });
          },
        }),
        {
          name: 'feature-flags',
          // LocalStorage に永続化
        }
      )
    )
  );

persist ミドルウェアにより、フラグの状態が LocalStorage に保存され、ページをリロードしても状態が維持されます。また、devtools ミドルウェアにより、Redux DevTools でデバッグが可能になります。

カスタムフックの作成

フィーチャーフラグを簡単に使えるカスタムフックを作成します。

typescript// hooks/useFeatureFlag.ts
import { useFeatureFlagStore } from '@/store/featureFlagStore';
import type { FeatureFlagKey } from '@/types/featureFlags';

/**
 * フィーチャーフラグを取得するカスタムフック
 * @param key フラグのキー
 * @returns フラグが有効かどうか
 */
export const useFeatureFlag = (
  key: FeatureFlagKey
): boolean => {
  return useFeatureFlagStore(
    (state) => state.flags[key] ?? false
  );
};

このフックを使うことで、コンポーネント内で簡潔にフラグの状態を取得できます。

typescript/**
 * 複数のフィーチャーフラグを取得するカスタムフック
 * @param keys フラグのキー配列
 * @returns 各フラグの状態を含むオブジェクト
 */
export const useFeatureFlags = (
  keys: FeatureFlagKey[]
): Record<FeatureFlagKey, boolean> => {
  return useFeatureFlagStore((state) => {
    const result: Partial<Record<FeatureFlagKey, boolean>> =
      {};
    keys.forEach((key) => {
      result[key] = state.flags[key] ?? false;
    });
    return result as Record<FeatureFlagKey, boolean>;
  });
};

複数のフラグを一度に取得したい場合は、このフックが便利です。

コンポーネントでの使用例

作成したフックをコンポーネントで使ってみましょう。

typescript// components/Dashboard.tsx
import { useFeatureFlag } from '@/hooks/useFeatureFlag';

export const Dashboard = () => {
  // 新しいダッシュボードが有効かチェック
  const isNewDashboardEnabled =
    useFeatureFlag('newDashboard');

  return (
    <div>
      {isNewDashboardEnabled ? (
        <NewDashboard />
      ) : (
        <LegacyDashboard />
      )}
    </div>
  );
};

フラグの状態に応じて、異なるコンポーネントを表示できます。

typescript// components/FeatureToggle.tsx
import type { ReactNode } from 'react';
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
import type { FeatureFlagKey } from '@/types/featureFlags';

interface FeatureToggleProps {
  feature: FeatureFlagKey;
  children: ReactNode;
  fallback?: ReactNode;
}

/**
 * フィーチャーフラグに基づいて表示を切り替えるコンポーネント
 */
export const FeatureToggle = ({
  feature,
  children,
  fallback = null,
}: FeatureToggleProps) => {
  const isEnabled = useFeatureFlag(feature);

  return <>{isEnabled ? children : fallback}</>;
};

FeatureToggle コンポーネントを使えば、宣言的にフラグによる表示切り替えができます。

typescript// 使用例
export const App = () => {
  return (
    <FeatureToggle
      feature='darkMode'
      fallback={<LightTheme />}
    >
      <DarkTheme />
    </FeatureToggle>
  );
};

このように、FeatureToggle コンポーネントでラップするだけで、簡単にフィーチャーフラグを適用できます。

ステップ 2: リモート設定との連携

次に、サーバーからフィーチャーフラグの設定を取得し、ストアに反映する仕組みを実装します。

API クライアントの作成

まず、フィーチャーフラグ設定を取得する API クライアントを作成します。

typescript// api/featureFlagClient.ts
import type {
  FeatureFlag,
  FeatureFlagKey,
} from '@/types/featureFlags';

/**
 * リモート設定のレスポンス型
 */
interface RemoteConfigResponse {
  flags: FeatureFlag[];
  version: string;
  updatedAt: string;
}

レスポンス型を定義することで、API からの返り値の型安全性を確保します。

typescript/**
 * フィーチャーフラグ設定を取得
 */
export const fetchFeatureFlags =
  async (): Promise<RemoteConfigResponse> => {
    try {
      const response = await fetch('/api/feature-flags', {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      });

      if (!response.ok) {
        throw new Error(
          `HTTP error! status: ${response.status}`
        );
      }

      const data: RemoteConfigResponse =
        await response.json();
      return data;
    } catch (error) {
      console.error(
        'Failed to fetch feature flags:',
        error
      );
      throw error;
    }
  };

エラーハンドリングを適切に行い、失敗時には例外をスローします。

サーバーサイドの実装例

Next.js の API Routes を使って、フィーチャーフラグ設定を返すエンドポイントを実装します。

typescript// pages/api/feature-flags.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import type { FeatureFlag } from '@/types/featureFlags';

interface RemoteConfigResponse {
  flags: FeatureFlag[];
  version: string;
  updatedAt: string;
}

/**
 * フィーチャーフラグ設定を返す API エンドポイント
 */
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<
    RemoteConfigResponse | { error: string }
  >
) {
  if (req.method !== 'GET') {
    return res
      .status(405)
      .json({ error: 'Method not allowed' });
  }

  try {
    // 実際には DB やリモート設定サービスから取得
    const flags: FeatureFlag[] = [
      {
        key: 'newDashboard',
        enabled: true,
        description: '新しいダッシュボード UI',
        segments: ['beta', 'premium'],
      },
      {
        key: 'darkMode',
        enabled: true,
        description: 'ダークモード対応',
      },
      {
        key: 'experimentalSearch',
        enabled: false,
        description: '実験的な検索機能',
        segments: ['internal'],
      },
    ];

    res.status(200).json({
      flags,
      version: '1.0.0',
      updatedAt: new Date().toISOString(),
    });
  } catch (error) {
    console.error('Error fetching feature flags:', error);
    res
      .status(500)
      .json({ error: 'Internal server error' });
  }
}

本番環境では、データベースや Firebase Remote Config などの外部サービスから設定を取得することが一般的です。

フラグ設定の初期化処理

アプリケーション起動時にリモート設定を取得し、ストアに反映する処理を実装します。

typescript// hooks/useInitializeFeatureFlags.ts
import { useEffect } from 'react';
import { useFeatureFlagStore } from '@/store/featureFlagStore';
import { fetchFeatureFlags } from '@/api/featureFlagClient';
import type { FeatureFlagKey } from '@/types/featureFlags';

/**
 * フィーチャーフラグを初期化するカスタムフック
 */
export const useInitializeFeatureFlags = () => {
  const { setFlags, setLoading, setError } =
    useFeatureFlagStore();

  useEffect(() => {
    const initializeFlags = async () => {
      setLoading(true);
      setError(null);

      try {
        // リモート設定を取得
        const { flags } = await fetchFeatureFlags();

        // フラグの状態をマップに変換
        const flagMap = flags.reduce((acc, flag) => {
          acc[flag.key] = flag.enabled;
          return acc;
        }, {} as Record<FeatureFlagKey, boolean>);

        // ストアに反映
        setFlags(flagMap);
      } catch (error) {
        console.error(
          'Failed to initialize feature flags:',
          error
        );
        setError('フィーチャーフラグの取得に失敗しました');
      } finally {
        setLoading(false);
      }
    };

    initializeFlags();
  }, [setFlags, setLoading, setError]);
};

このフックをアプリケーションのルートコンポーネントで呼び出すことで、起動時に自動的にフラグ設定が取得されます。

アプリケーションへの統合

_app.tsx で初期化フックを使用します。

typescript// pages/_app.tsx
import type { AppProps } from 'next/app';
import { useInitializeFeatureFlags } from '@/hooks/useInitializeFeatureFlags';

function MyApp({ Component, pageProps }: AppProps) {
  // フィーチャーフラグの初期化
  useInitializeFeatureFlags();

  return <Component {...pageProps} />;
}

export default MyApp;

アプリケーション起動時に一度だけ実行され、リモート設定が取得されます。

ポーリングによる定期更新

フィーチャーフラグの設定を定期的に更新する仕組みも実装できます。

typescript// hooks/useFeatureFlagPolling.ts
import { useEffect, useRef } from 'react';
import { useFeatureFlagStore } from '@/store/featureFlagStore';
import { fetchFeatureFlags } from '@/api/featureFlagClient';
import type { FeatureFlagKey } from '@/types/featureFlags';

/**
 * フィーチャーフラグを定期的に更新するカスタムフック
 * @param intervalMs ポーリング間隔(ミリ秒)
 */
export const useFeatureFlagPolling = (
  intervalMs: number = 60000
) => {
  const { setFlags } = useFeatureFlagStore();
  const intervalRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    const updateFlags = async () => {
      try {
        const { flags } = await fetchFeatureFlags();
        const flagMap = flags.reduce((acc, flag) => {
          acc[flag.key] = flag.enabled;
          return acc;
        }, {} as Record<FeatureFlagKey, boolean>);
        setFlags(flagMap);
      } catch (error) {
        console.error(
          'Failed to update feature flags:',
          error
        );
      }
    };

    // 定期的に更新
    intervalRef.current = setInterval(
      updateFlags,
      intervalMs
    );

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [intervalMs, setFlags]);
};

このフックを使うことで、サーバー側でフラグを変更すると、クライアント側にも自動的に反映されます。

typescript// 使用例
function MyApp({ Component, pageProps }: AppProps) {
  useInitializeFeatureFlags();
  // 1分ごとにフラグを更新
  useFeatureFlagPolling(60000);

  return <Component {...pageProps} />;
}

ポーリング間隔は、アプリケーションの要件に応じて調整してください。

ステップ 3: ユーザーセグメント対応

特定のユーザーグループに対してのみフラグを有効化する仕組みを実装します。

ユーザー情報の型定義

まず、ユーザー情報とセグメントの型を定義します。

typescript// types/user.ts

/**
 * ユーザーセグメント
 */
export type UserSegment =
  | 'internal' // 社内ユーザー
  | 'beta' // ベータテスター
  | 'premium' // 有料会員
  | 'free' // 無料会員
  | 'all'; // 全ユーザー

/**
 * ユーザー情報
 */
export interface User {
  id: string;
  email: string;
  segments: UserSegment[];
}

ユーザーは複数のセグメントに所属できるように設計します。

セグメント判定ロジックの実装

ユーザーが特定のセグメントに所属しているかを判定する関数を作成します。

typescript// utils/segmentMatcher.ts
import type { User, UserSegment } from '@/types/user';
import type { FeatureFlag } from '@/types/featureFlags';

/**
 * ユーザーがフィーチャーフラグの対象セグメントに含まれるか判定
 * @param user ユーザー情報
 * @param flag フィーチャーフラグ設定
 * @returns 対象セグメントに含まれる場合 true
 */
export const isUserInSegment = (
  user: User | null,
  flag: FeatureFlag
): boolean => {
  // ユーザー情報がない場合は false
  if (!user) {
    return false;
  }

  // セグメント指定がない場合は全ユーザー対象
  if (!flag.segments || flag.segments.length === 0) {
    return true;
  }

  // 'all' が含まれている場合は全ユーザー対象
  if (flag.segments.includes('all')) {
    return true;
  }

  // ユーザーのセグメントとフラグのセグメントが重複するか確認
  return flag.segments.some((segment) =>
    user.segments.includes(segment as UserSegment)
  );
};

この関数により、ユーザーがフラグの対象セグメントに含まれるかを簡潔に判定できます。

ユーザー情報を考慮したストアの拡張

Zustand ストアにユーザー情報を追加します。

typescript// store/featureFlagStore.ts (拡張版)
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import type {
  FeatureFlagKey,
  FeatureFlag,
} from '@/types/featureFlags';
import type { User } from '@/types/user';
import { isUserInSegment } from '@/utils/segmentMatcher';

interface FeatureFlagState {
  flags: Record<FeatureFlagKey, boolean>;
  flagConfigs: FeatureFlag[];
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

interface FeatureFlagActions {
  getFlag: (key: FeatureFlagKey) => boolean;
  setFlag: (key: FeatureFlagKey, enabled: boolean) => void;
  setFlags: (
    flags: Record<FeatureFlagKey, boolean>
  ) => void;
  setFlagConfigs: (configs: FeatureFlag[]) => void;
  setUser: (user: User | null) => void;
  clearFlags: () => void;
  setLoading: (isLoading: boolean) => void;
  setError: (error: string | null) => void;
}

type FeatureFlagStore = FeatureFlagState &
  FeatureFlagActions;

const defaultFlags: Record<FeatureFlagKey, boolean> = {
  newDashboard: false,
  darkMode: true,
  experimentalSearch: false,
  premiumFeatures: false,
  betaAnalytics: false,
};

ストアに userflagConfigs を追加し、セグメント判定に使用します。

typescriptexport const useFeatureFlagStore =
  create<FeatureFlagStore>()(
    devtools(
      persist(
        (set, get) => ({
          flags: defaultFlags,
          flagConfigs: [],
          user: null,
          isLoading: false,
          error: null,

          getFlag: (key) => {
            const state = get();
            const flagConfig = state.flagConfigs.find(
              (f) => f.key === key
            );

            // フラグ設定が見つからない場合はデフォルト値
            if (!flagConfig) {
              return state.flags[key] ?? false;
            }

            // フラグが無効の場合は false
            if (!flagConfig.enabled) {
              return false;
            }

            // セグメント判定
            if (!isUserInSegment(state.user, flagConfig)) {
              return false;
            }

            return true;
          },

          setFlag: (key, enabled) => {
            set((state) => ({
              flags: {
                ...state.flags,
                [key]: enabled,
              },
            }));
          },

          setFlags: (flags) => {
            set({ flags });
          },

          setFlagConfigs: (configs) => {
            set({ flagConfigs: configs });
          },

          setUser: (user) => {
            set({ user });
          },

          clearFlags: () => {
            set({
              flags: defaultFlags,
              flagConfigs: [],
              user: null,
            });
          },

          setLoading: (isLoading) => {
            set({ isLoading });
          },

          setError: (error) => {
            set({ error });
          },
        }),
        {
          name: 'feature-flags',
        }
      )
    )
  );

getFlag メソッドでセグメント判定を行い、ユーザーが対象セグメントに含まれない場合は false を返します。

初期化処理の更新

ユーザー情報とフラグ設定を同時に取得して初期化します。

typescript// hooks/useInitializeFeatureFlags.ts (拡張版)
import { useEffect } from 'react';
import { useFeatureFlagStore } from '@/store/featureFlagStore';
import { fetchFeatureFlags } from '@/api/featureFlagClient';
import type { User } from '@/types/user';

/**
 * ユーザー情報を取得する関数
 * 実際には認証サービスから取得
 */
const fetchCurrentUser = async (): Promise<User | null> => {
  try {
    const response = await fetch('/api/user/me');
    if (!response.ok) {
      return null;
    }
    return await response.json();
  } catch (error) {
    console.error('Failed to fetch user:', error);
    return null;
  }
};

export const useInitializeFeatureFlags = () => {
  const {
    setFlags,
    setFlagConfigs,
    setUser,
    setLoading,
    setError,
  } = useFeatureFlagStore();

  useEffect(() => {
    const initializeFlags = async () => {
      setLoading(true);
      setError(null);

      try {
        // ユーザー情報とフラグ設定を並行取得
        const [user, { flags }] = await Promise.all([
          fetchCurrentUser(),
          fetchFeatureFlags(),
        ]);

        // ストアに反映
        setUser(user);
        setFlagConfigs(flags);

        // フラグの状態マップを作成
        const flagMap = flags.reduce((acc, flag) => {
          acc[flag.key] = flag.enabled;
          return acc;
        }, {} as Record<import('@/types/featureFlags').FeatureFlagKey, boolean>);

        setFlags(flagMap);
      } catch (error) {
        console.error(
          'Failed to initialize feature flags:',
          error
        );
        setError('初期化に失敗しました');
      } finally {
        setLoading(false);
      }
    };

    initializeFlags();
  }, [
    setFlags,
    setFlagConfigs,
    setUser,
    setLoading,
    setError,
  ]);
};

Promise.all を使うことで、ユーザー情報とフラグ設定を並行で取得し、初期化時間を短縮できます。

セグメント対応のテストケース

セグメント判定ロジックが正しく動作するか、テストを書いて確認します。

typescript// __tests__/segmentMatcher.test.ts
import { isUserInSegment } from '@/utils/segmentMatcher';
import type { User } from '@/types/user';
import type { FeatureFlag } from '@/types/featureFlags';

describe('isUserInSegment', () => {
  const betaUser: User = {
    id: '1',
    email: 'beta@example.com',
    segments: ['beta'],
  };

  const premiumUser: User = {
    id: '2',
    email: 'premium@example.com',
    segments: ['premium'],
  };

  const freeUser: User = {
    id: '3',
    email: 'free@example.com',
    segments: ['free'],
  };

  test('セグメント指定なしのフラグは全ユーザーが対象', () => {
    const flag: FeatureFlag = {
      key: 'darkMode',
      enabled: true,
    };

    expect(isUserInSegment(betaUser, flag)).toBe(true);
    expect(isUserInSegment(freeUser, flag)).toBe(true);
  });

  test('ベータユーザーのみが対象のフラグ', () => {
    const flag: FeatureFlag = {
      key: 'betaAnalytics',
      enabled: true,
      segments: ['beta'],
    };

    expect(isUserInSegment(betaUser, flag)).toBe(true);
    expect(isUserInSegment(freeUser, flag)).toBe(false);
  });

  test('複数セグメントが指定されたフラグ', () => {
    const flag: FeatureFlag = {
      key: 'newDashboard',
      enabled: true,
      segments: ['beta', 'premium'],
    };

    expect(isUserInSegment(betaUser, flag)).toBe(true);
    expect(isUserInSegment(premiumUser, flag)).toBe(true);
    expect(isUserInSegment(freeUser, flag)).toBe(false);
  });

  test('ユーザー情報がない場合は false', () => {
    const flag: FeatureFlag = {
      key: 'premiumFeatures',
      enabled: true,
      segments: ['premium'],
    };

    expect(isUserInSegment(null, flag)).toBe(false);
  });
});

テストを実行して、セグメント判定が期待通りに動作することを確認しましょう。

bashyarn test segmentMatcher.test.ts

テストが成功すれば、セグメント判定ロジックは正しく実装されています。

フィーチャーフラグの実践的な活用例

実際のアプリケーションでフィーチャーフラグを活用する例を見ていきます。

例 1: 段階的ロールアウト

新しい検索機能を段階的にリリースする例です。

typescript// components/SearchBar.tsx
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
import { ExperimentalSearch } from './ExperimentalSearch';
import { LegacySearch } from './LegacySearch';

export const SearchBar = () => {
  const isExperimentalSearchEnabled = useFeatureFlag(
    'experimentalSearch'
  );

  return (
    <div>
      {isExperimentalSearchEnabled ? (
        <ExperimentalSearch />
      ) : (
        <LegacySearch />
      )}
    </div>
  );
};

最初は internal セグメントのみに公開し、問題がなければ段階的に拡大していきます。

#フェーズ対象セグメント期間
1内部テストinternal1 週間
2ベータテストinternal, beta2 週間
3部分公開internal, beta, premium1 週間
4全体公開all-

例 2: A/B テストの実装

異なる UI パターンを比較検証する例です。

typescript// components/PricingPage.tsx
import { useFeatureFlag } from '@/hooks/useFeatureFlag';

export const PricingPage = () => {
  const isNewPricingUIEnabled =
    useFeatureFlag('newPricingUI');

  return (
    <div>
      {isNewPricingUIEnabled ? (
        <NewPricingLayout />
      ) : (
        <OriginalPricingLayout />
      )}
    </div>
  );
};

ユーザーをランダムに 2 つのグループに分け、どちらの UI がコンバージョン率が高いかを測定できます。

例 3: 緊急機能停止

問題が発生した機能を即座に無効化する例です。

typescript// components/PaymentForm.tsx
import { useFeatureFlag } from '@/hooks/useFeatureFlag';

export const PaymentForm = () => {
  const isNewPaymentFlowEnabled = useFeatureFlag(
    'newPaymentFlow'
  );

  if (!isNewPaymentFlowEnabled) {
    // フラグが無効の場合は旧バージョンにフォールバック
    return <LegacyPaymentForm />;
  }

  return <NewPaymentForm />;
};

新しい決済フローに問題が発生した場合、サーバー側でフラグを OFF にするだけで、すべてのユーザーが旧バージョンに戻ります。

パフォーマンス最適化

フィーチャーフラグの使用によるパフォーマンスへの影響を最小限に抑える方法を紹介します。

セレクターの最適化

Zustand のセレクター機能を使って、不要な再レンダリングを防ぎます。

typescript// 悪い例:ストア全体を購読
const store = useFeatureFlagStore();
const isEnabled = store.flags['newDashboard'];

// 良い例:必要な部分のみを購読
const isEnabled = useFeatureFlagStore(
  (state) => state.flags['newDashboard']
);

セレクターを使うことで、他のフラグが変更されても再レンダリングされません。

メモ化の活用

複数のフラグを使う場合は、メモ化を活用します。

typescript// hooks/useFeatureFlags.ts (最適化版)
import { useMemo } from 'react';
import { useFeatureFlagStore } from '@/store/featureFlagStore';
import type { FeatureFlagKey } from '@/types/featureFlags';

export const useFeatureFlags = (
  keys: FeatureFlagKey[]
): Record<FeatureFlagKey, boolean> => {
  // keys 配列をソートして、順序が変わっても再計算されないようにする
  const sortedKeys = useMemo(
    () => [...keys].sort(),
    [keys]
  );

  return useFeatureFlagStore((state) => {
    const result: Partial<Record<FeatureFlagKey, boolean>> =
      {};
    sortedKeys.forEach((key) => {
      result[key] = state.flags[key] ?? false;
    });
    return result as Record<FeatureFlagKey, boolean>;
  });
};

useMemo を使うことで、keys 配列が変更されない限り、同じ参照を返します。

まとめ

本記事では、Zustand を使ったフィーチャーフラグシステムの実装方法を解説しました。重要なポイントをおさらいしましょう。

実装のポイント

  1. Zustand による状態管理: 軽量でシンプルな API により、フラグの状態を効率的に管理できます
  2. リモート設定との連携: サーバー側で一元管理し、クライアント側に動的に反映できます
  3. ユーザーセグメント対応: 特定のユーザーグループに対してのみ機能を有効化できます
  4. 型安全性: TypeScript により、フラグのキー名やユーザーセグメントを型安全に扱えます
  5. パフォーマンス最適化: セレクターやメモ化により、不要な再レンダリングを防げます

運用上の注意点

フィーチャーフラグを運用する際は、以下の点に注意してください。

#注意点対策
1フラグの増加による複雑化定期的に不要なフラグを削除する
2テストの複雑化フラグの組み合わせパターンをテストする
3デバッグの困難化ログにフラグの状態を記録する
4セキュリティリスク機密情報をフラグに含めない
5パフォーマンス低下フラグの数を適切に管理する

今後の拡張

本記事で実装したシステムは、以下のような拡張が可能です。

  • 分析ツールとの連携: Google Analytics などでフラグごとのユーザー行動を分析
  • 管理画面の構築: フラグの設定を GUI で管理できるダッシュボード
  • ロールバック機能: 問題が発生した場合に自動的にフラグを無効化
  • 段階的ロールアウト: パーセンテージベースで徐々にユーザーを増やす
  • 依存関係管理: フラグ間の依存関係を定義し、整合性を保つ

フィーチャーフラグは、モダンなソフトウェア開発において欠かせない手法となっています。Zustand のシンプルさを活かして、柔軟で保守性の高いシステムを構築してみてください。

関連リンク