T-CREATOR

本番計測とトレース:Zustand の更新頻度・差分サイズを可視化して改善サイクル化

本番計測とトレース:Zustand の更新頻度・差分サイズを可視化して改善サイクル化

Zustand を使った状態管理は、そのシンプルさと高いパフォーマンスで多くの開発者に支持されています。しかし、本番環境で実際にどれだけの頻度で状態が更新されているのか、どのくらいのデータ量が変更されているのかを把握できていますか?

開発環境では問題なく動作していても、本番環境では予想外の頻度で状態更新が発生し、パフォーマンスに影響を与えているかもしれません。この記事では、Zustand の更新頻度や差分サイズを可視化し、継続的な改善サイクルを回すための実践的な方法をご紹介します。実際に本番環境で動作するトレースシステムを構築し、データドリブンな最適化を実現しましょう。

背景

Zustand の状態管理の特性

Zustand は React の状態管理ライブラリとして、Redux や MobX と比較して非常にシンプルな API を提供しています。ストアの作成からコンポーネントでの利用まで、最小限のボイラープレートで状態管理を実現できるのが大きな魅力です。

しかし、そのシンプルさゆえに、状態の更新パターンやパフォーマンス特性を把握しづらいという側面もあります。特に以下のような状況では、本番環境での動作を詳細に観察する必要があるでしょう。

  • 複数のストアが連携して動作する場合
  • WebSocket などのリアルタイム通信で頻繁に状態が更新される場合
  • 大きなオブジェクトや配列を状態として管理している場合

Zustand の状態更新フロー

以下の図は、Zustand における基本的な状態更新フローを示しています。この流れを理解することで、どこで計測を行うべきかが見えてきます。

mermaidflowchart TD
  action["ユーザーアクション"] -->|呼び出し| setter["setState 関数"]
  setter -->|状態更新| store["Zustand Store"]
  store -->|通知| subscribers["購読中の<br/>コンポーネント"]
  subscribers -->|再レンダリング| ui["UI 更新"]
  store -.->|計測ポイント| metrics["メトリクス収集"]

図で理解できる要点:

  • setState 関数の呼び出しから UI 更新までの一連の流れ
  • メトリクス収集は状態更新のタイミングで実施
  • 購読パターンによって影響範囲が異なる

本番環境特有の課題

開発環境と本番環境では、状態管理の動作特性が大きく異なることがあります。開発時には気づかなかった以下のような問題が、本番環境で顕在化するケースが少なくありません。

#課題開発環境本番環境
1データ量サンプルデータ(小)実データ(大)
2更新頻度低頻度のテスト高頻度の実トラフィック
3同時アクセス単一ユーザー多数のユーザー
4ネットワーク遅延ローカル(低遅延)実ネットワーク(高遅延)

特に、WebSocket やサーバーサイドイベントを使用している場合、本番環境では開発時の数十倍から数百倍の頻度で状態更新が発生することもあります。

課題

見えないパフォーマンスボトルネック

Zustand を使ったアプリケーションで、以下のような症状に悩まされたことはないでしょうか。

ユーザーから「なんとなく重い」「操作に引っかかりを感じる」といったフィードバックを受けても、具体的にどこが問題なのか特定できないことがあります。Chrome DevTools のプロファイラーでは、再レンダリングの回数は確認できますが、それが Zustand の状態更新によるものなのか、他の要因によるものなのかを切り分けるのは容易ではありません。

計測できない問題点の可視化

従来のアプローチでは、以下のような情報が可視化されていませんでした。

mermaidflowchart LR
  subgraph problems["可視化されていない問題"]
    freq["更新頻度<br/>(1秒に何回?)"]
    size["差分サイズ<br/>(何バイト変更?)"]
    which["どのストア?<br/>(複数ストアの特定)"]
    impact["影響範囲<br/>(何コンポーネント?)"]
  end

  problems -.->|結果| unknown["パフォーマンス<br/>問題の原因不明"]
  unknown -.->|対応困難| slowdown["体感速度の低下"]

図で理解できる要点:

  • 更新頻度、差分サイズ、対象ストア、影響範囲が不明
  • 原因特定ができず改善が困難
  • ユーザー体験の低下につながる

具体的な課題一覧

本番環境での Zustand パフォーマンス課題は、主に以下の 4 つに分類できます。

#課題カテゴリ詳細影響
1更新頻度の可視化不足1 秒間に何回状態更新が発生しているか不明不要な更新の検出不可
2差分サイズの把握困難変更されたデータ量がわからないメモリ・CPU 使用量の予測不可
3ストア別の分析不可複数ストア使用時にどれが原因か特定できないピンポイントな最適化不可
4時系列データの欠如過去のパフォーマンス推移が追えない改善効果の測定不可

これらの課題を解決するには、本番環境で動作する計測システムが必要です。ただし、計測自体がパフォーマンスに悪影響を与えてはいけないため、軽量で効率的な実装が求められます。

エラーコード例:計測なしの問題

計測機能がない場合、以下のようなコンソールエラーやパフォーマンス警告が出ても、原因の特定が困難です。

typescript// Chrome DevTools Console での警告例
Warning: Cannot update a component while rendering a different component.
// → どのストアの更新が原因か特定できない

Error: Maximum update depth exceeded.
// → 無限ループしているストア更新を特定できない

エラーコード: Warning: Cannot update a component... (React warning)

発生条件: Zustand の setState が render 中に呼ばれている

解決方法:

  1. どのストアが問題か計測システムで特定
  2. 更新タイミングをトレースで確認
  3. useEffect や適切なライフサイクルに移動

解決策

計測システムのアーキテクチャ

Zustand の状態更新を計測するには、ミドルウェアを活用するのが最も効果的です。以下のアーキテクチャで、本番環境でも動作する軽量な計測システムを構築できます。

mermaidflowchart TB
  app["React アプリ"] -->|使用| store["Zustand Store"]
  store -->|ミドルウェア| trace["トレースミドルウェア"]
  trace -->|計測| metrics["メトリクス収集"]

  subgraph measurement["計測内容"]
    m1["更新頻度"]
    m2["差分サイズ"]
    m3["タイムスタンプ"]
    m4["ストア名"]
  end

  metrics --> measurement
  measurement -->|送信| backend["バックエンドAPI"]
  backend -->|保存| db[("時系列DB")]
  db -->|可視化| dashboard["ダッシュボード"]

図で理解できる要点:

  • ミドルウェアで非侵襲的に計測
  • 必要な 4 つの指標を自動収集
  • バックエンドで集約・可視化

トレースミドルウェアの実装

まず、Zustand のミドルウェアとして動作するトレース機能を実装します。このミドルウェアは、すべての状態更新をフックし、必要な情報を記録します。

typescriptimport {
  StateCreator,
  StoreMutatorIdentifier,
} from 'zustand';

// トレースデータの型定義
interface TraceData {
  storeName: string;
  timestamp: number;
  updateCount: number;
  diffSize: number;
  prevState: unknown;
  nextState: unknown;
}

型定義では、ストア名、タイムスタンプ、更新回数、差分サイズ、そして状態の変更前後のスナップショットを保持します。これにより、後から詳細な分析が可能になりますね。

typescript// トレースミドルウェアの型定義
type TraceMiddleware = <
  T,
  Mps extends [StoreMutatorIdentifier, unknown][] = [],
  Mcs extends [StoreMutatorIdentifier, unknown][] = []
>(
  initializer: StateCreator<T, Mps, Mcs>,
  storeName: string
) => StateCreator<T, Mps, Mcs>;
typescript// オブジェクトの差分サイズを計算するヘルパー関数
function calculateDiffSize(
  prev: unknown,
  next: unknown
): number {
  try {
    // JSON文字列化してバイトサイズを計算
    const prevStr = JSON.stringify(prev);
    const nextStr = JSON.stringify(next);

    // 差分がある部分のサイズを推定
    const prevSize = new Blob([prevStr]).size;
    const nextSize = new Blob([nextStr]).size;

    return Math.abs(nextSize - prevSize);
  } catch (error) {
    console.error('Failed to calculate diff size:', error);
    return 0;
  }
}

差分サイズの計算では、JSON シリアライズした文字列のバイトサイズを比較します。完全な差分アルゴリズムではありませんが、本番環境でのオーバーヘッドを最小限に抑えつつ、十分な精度で変更量を把握できます。

typescript// メトリクス収集用のバッファ(バッチ送信用)
const metricsBuffer: TraceData[] = [];
const BUFFER_SIZE = 10; // 10件たまったら送信
const FLUSH_INTERVAL = 5000; // または5秒ごとに送信

// バッファをフラッシュする関数
function flushMetrics() {
  if (metricsBuffer.length === 0) return;

  // バックエンドAPIに送信(非同期)
  sendMetricsToBackend([...metricsBuffer]).catch((error) =>
    console.error('Failed to send metrics:', error)
  );

  // バッファをクリア
  metricsBuffer.length = 0;
}
typescript// 定期的なフラッシュを設定
if (typeof window !== 'undefined') {
  setInterval(flushMetrics, FLUSH_INTERVAL);

  // ページ離脱時にも送信
  window.addEventListener('beforeunload', flushMetrics);
}

メトリクスはバッファリングしてバッチ送信することで、ネットワークリクエストの回数を減らし、パフォーマンスへの影響を最小化します。

typescript// トレースミドルウェアの実装
export const trace: TraceMiddleware = (
  initializer,
  storeName
) => {
  return (set, get, api) => {
    // 更新カウンター
    let updateCount = 0;

    // setをラップして計測機能を追加
    const tracedSet: typeof set = (partial, replace) => {
      const prevState = get();

      // 元のsetを実行
      set(partial, replace);

      const nextState = get();
      updateCount++;

      // トレースデータを記録
      const traceData: TraceData = {
        storeName,
        timestamp: Date.now(),
        updateCount,
        diffSize: calculateDiffSize(prevState, nextState),
        prevState,
        nextState,
      };

      // バッファに追加
      metricsBuffer.push(traceData);

      // バッファが満杯ならフラッシュ
      if (metricsBuffer.length >= BUFFER_SIZE) {
        flushMetrics();
      }
    };

    // ラップしたsetでinitializerを実行
    return initializer(tracedSet, get, api);
  };
};

このミドルウェアは、set 関数をラップして、すべての状態更新を自動的に記録します。開発者は既存のストア定義を変更することなく、計測機能を追加できるのです。

バックエンドへのメトリクス送信

計測したデータをバックエンドに送信する関数を実装します。本番環境での安定性を考慮し、エラーハンドリングとリトライ機能を含めます。

typescript// メトリクス送信APIの型定義
interface MetricsPayload {
  metrics: TraceData[];
  sessionId: string;
  userAgent: string;
  timestamp: number;
}
typescript// セッションIDの生成(ページロード時に1回だけ)
const sessionId = `${Date.now()}-${Math.random()
  .toString(36)
  .slice(2)}`;

async function sendMetricsToBackend(
  metrics: TraceData[]
): Promise<void> {
  const payload: MetricsPayload = {
    metrics,
    sessionId,
    userAgent: navigator.userAgent,
    timestamp: Date.now(),
  };

  try {
    const response = await fetch('/api/metrics/zustand', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
    });

    if (!response.ok) {
      throw new Error(
        `HTTP ${response.status}: ${response.statusText}`
      );
    }
  } catch (error) {
    // 本番環境ではサイレントに失敗させる
    if (process.env.NODE_ENV === 'development') {
      console.error('Metrics送信エラー:', error);
    }
  }
}

エラーが発生しても、メインアプリケーションの動作に影響を与えないよう、エラーはキャッチして適切に処理します。開発環境ではログ出力し、本番環境ではサイレントに失敗させることで、ユーザー体験を損ないません。

パフォーマンスへの影響を最小化

計測自体がパフォーマンスボトルネックにならないよう、以下の最適化を施します。

typescript// 本番環境でのサンプリング(全トラフィックの10%のみ計測)
const SAMPLING_RATE =
  process.env.NODE_ENV === 'production' ? 0.1 : 1.0;

function shouldTrace(): boolean {
  return Math.random() < SAMPLING_RATE;
}
typescript// サンプリングを考慮したトレースミドルウェア
export const traceWithSampling: TraceMiddleware = (
  initializer,
  storeName
) => {
  // サンプリング判定(セッション単位)
  const isTracingEnabled = shouldTrace();

  if (!isTracingEnabled) {
    // トレース無効ならそのまま返す
    return initializer;
  }

  // トレース有効なら計測機能を追加
  return trace(initializer, storeName);
};

サンプリングにより、本番環境でのオーバーヘッドを大幅に削減できます。10%のトラフィックでも、十分な統計的有意性を持ったデータを収集できるでしょう。

具体例

実際のストアへの適用

それでは、実際のアプリケーションでトレースミドルウェアを使用する例を見ていきましょう。ここでは、ユーザー情報と通知を管理する 2 つのストアに計測機能を追加します。

typescriptimport { create } from 'zustand';
import { traceWithSampling } from './trace-middleware';

// ユーザー情報ストアの型定義
interface UserState {
  user: {
    id: string;
    name: string;
    email: string;
  } | null;
  setUser: (user: UserState['user']) => void;
  clearUser: () => void;
}
typescript// トレース機能付きユーザーストア
export const useUserStore = create<UserState>()(
  traceWithSampling(
    (set) => ({
      user: null,

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

      clearUser: () => {
        set({ user: null });
      },
    }),
    'UserStore' // ストア名を指定
  )
);

ストア名を指定することで、複数のストアを使用している場合でも、どのストアの更新なのか簡単に識別できます。

typescript// 通知ストアの型定義
interface NotificationState {
  notifications: Array<{
    id: string;
    message: string;
    type: 'info' | 'success' | 'warning' | 'error';
    timestamp: number;
  }>;
  addNotification: (
    notification: Omit<
      NotificationState['notifications'][0],
      'id' | 'timestamp'
    >
  ) => void;
  removeNotification: (id: string) => void;
  clearAll: () => void;
}
typescript// トレース機能付き通知ストア
export const useNotificationStore =
  create<NotificationState>()(
    traceWithSampling(
      (set) => ({
        notifications: [],

        addNotification: (notification) => {
          set((state) => ({
            notifications: [
              ...state.notifications,
              {
                ...notification,
                id: `notif-${Date.now()}-${Math.random()}`,
                timestamp: Date.now(),
              },
            ],
          }));
        },

        removeNotification: (id) => {
          set((state) => ({
            notifications: state.notifications.filter(
              (n) => n.id !== id
            ),
          }));
        },

        clearAll: () => {
          set({ notifications: [] });
        },
      }),
      'NotificationStore' // ストア名を指定
    )
  );

通知ストアでは配列の更新が頻繁に発生するため、差分サイズの計測が特に有用です。通知が追加されるたびにどれだけのデータ量が変更されているか把握できますね。

バックエンド API の実装(Next.js)

フロントエンドから送信されたメトリクスを受け取る API エンドポイントを実装します。

typescript// pages/api/metrics/zustand.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';

// バリデーションスキーマ
const TraceDataSchema = z.object({
  storeName: z.string(),
  timestamp: z.number(),
  updateCount: z.number(),
  diffSize: z.number(),
  prevState: z.unknown(),
  nextState: z.unknown(),
});
typescriptconst MetricsPayloadSchema = z.object({
  metrics: z.array(TraceDataSchema),
  sessionId: z.string(),
  userAgent: z.string(),
  timestamp: z.number(),
});

type MetricsPayload = z.infer<typeof MetricsPayloadSchema>;

Zod を使用したバリデーションにより、不正なデータを早期に検出し、データベースの整合性を保ちます。

typescriptexport default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // POSTメソッドのみ許可
  if (req.method !== 'POST') {
    return res
      .status(405)
      .json({ error: 'Method Not Allowed' });
  }

  try {
    // リクエストボディをバリデーション
    const payload = MetricsPayloadSchema.parse(req.body);

    // データベースに保存
    await saveMetricsToDatabase(payload);

    res.status(200).json({ success: true });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return res.status(400).json({
        error: 'Invalid payload',
        details: error.errors,
      });
    }

    console.error('Failed to save metrics:', error);
    res
      .status(500)
      .json({ error: 'Internal Server Error' });
  }
}

エラーコード: 400 Bad Request - Invalid payload

発生条件: フロントエンドから送信されたデータがスキーマに適合しない

解決方法:

  1. フロントエンドの TraceData 型とバックエンドのスキーマを一致させる
  2. details フィールドで具体的な不一致箇所を確認
  3. 型定義を共通化(モノレポ推奨)

データベースへの保存(時系列データ)

計測データは時系列データベースに保存することで、効率的なクエリと可視化が可能になります。

typescriptimport { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function saveMetricsToDatabase(
  payload: MetricsPayload
): Promise<void> {
  // トランザクションで一括保存
  await prisma.$transaction(
    payload.metrics.map((metric) =>
      prisma.zustandMetrics.create({
        data: {
          storeName: metric.storeName,
          timestamp: new Date(metric.timestamp),
          updateCount: metric.updateCount,
          diffSize: metric.diffSize,
          sessionId: payload.sessionId,
          userAgent: payload.userAgent,
          // 状態のスナップショットはJSONとして保存
          prevState: metric.prevState as object,
          nextState: metric.nextState as object,
        },
      })
    )
  );
}
sql-- Prisma Schema (schema.prisma)
model ZustandMetrics {
  id          String   @id @default(cuid())
  storeName   String
  timestamp   DateTime
  updateCount Int
  diffSize    Int
  sessionId   String
  userAgent   String
  prevState   Json
  nextState   Json
  createdAt   DateTime @default(now())

  @@index([storeName, timestamp])
  @@index([sessionId])
}

インデックスを適切に設定することで、ストア別・時間範囲別のクエリを高速化できます。

可視化ダッシュボードの構築

収集したデータを可視化し、改善のインサイトを得られるダッシュボードを作成しましょう。

typescript// components/MetricsDashboard.tsx
import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2';

interface AggregatedMetrics {
  storeName: string;
  hourlyUpdates: number;
  averageDiffSize: number;
  peakUpdateRate: number;
  timestamp: Date;
}
typescriptexport function MetricsDashboard() {
  const [metrics, setMetrics] = useState<
    AggregatedMetrics[]
  >([]);
  const [selectedStore, setSelectedStore] =
    useState<string>('all');

  useEffect(() => {
    // 過去24時間のメトリクスを取得
    fetch('/api/metrics/zustand/aggregated?hours=24')
      .then((res) => res.json())
      .then((data) => setMetrics(data))
      .catch((err) =>
        console.error('Failed to load metrics:', err)
      );
  }, []);

  // ストア名のリストを抽出
  const storeNames = Array.from(
    new Set(metrics.map((m) => m.storeName))
  );

  return (
    <div className='metrics-dashboard'>
      <h2>Zustand メトリクスダッシュボード</h2>

      {/* ストア選択 */}
      <select
        value={selectedStore}
        onChange={(e) => setSelectedStore(e.target.value)}
      >
        <option value='all'>すべてのストア</option>
        {storeNames.map((name) => (
          <option key={name} value={name}>
            {name}
          </option>
        ))}
      </select>

      {/* グラフとテーブルは次のコードブロックで */}
    </div>
  );
}
typescript// 更新頻度のグラフデータを準備
const updateRateData = {
  labels: metrics.map((m) =>
    new Date(m.timestamp).toLocaleTimeString()
  ),
  datasets: [
    {
      label: '時間あたりの更新回数',
      data: metrics
        .filter(
          (m) =>
            selectedStore === 'all' ||
            m.storeName === selectedStore
        )
        .map((m) => m.hourlyUpdates),
      borderColor: 'rgb(75, 192, 192)',
      tension: 0.1,
    },
  ],
};

// 差分サイズのグラフデータを準備
const diffSizeData = {
  labels: metrics.map((m) =>
    new Date(m.timestamp).toLocaleTimeString()
  ),
  datasets: [
    {
      label: '平均差分サイズ(バイト)',
      data: metrics
        .filter(
          (m) =>
            selectedStore === 'all' ||
            m.storeName === selectedStore
        )
        .map((m) => m.averageDiffSize),
      borderColor: 'rgb(255, 99, 132)',
      tension: 0.1,
    },
  ],
};
typescript// ダッシュボードのレンダリング(続き)
return (
  <div className='metrics-dashboard'>
    {/* ... 前のコード ... */}

    <div className='charts'>
      <div className='chart-container'>
        <h3>更新頻度の推移</h3>
        <Line data={updateRateData} />
      </div>

      <div className='chart-container'>
        <h3>差分サイズの推移</h3>
        <Line data={diffSizeData} />
      </div>
    </div>

    {/* サマリーテーブル */}
    <MetricsSummaryTable metrics={metrics} />
  </div>
);

改善サイクルのフロー

計測データを活用した継続的な改善サイクルを確立します。以下の図は、データ収集から改善実施までの流れを示しています。

mermaidflowchart TD
  collect["1. データ収集<br/>(本番環境)"] --> analyze["2. 分析<br/>(ダッシュボード)"]
  analyze --> identify["3. ボトルネック<br/>特定"]
  identify --> plan["4. 改善策<br/>立案"]
  plan --> implement["5. 実装<br/>(最適化)"]
  implement --> deploy["6. デプロイ"]
  deploy --> collect

  identify -.->|例| issues["・高頻度更新<br/>・大サイズ差分<br/>・不要な再レンダリング"]
  plan -.->|対策| solutions["・セレクター最適化<br/>・状態の正規化<br/>・バッチ更新"]

図で理解できる要点:

  • 継続的なサイクルで改善を繰り返す
  • データドリブンな意思決定
  • 具体的な問題と対策を明確化

実際の改善事例

ダッシュボードで発見した問題と、その解決方法の具体例を見ていきましょう。

事例 1: 通知ストアの高頻度更新

ダッシュボードで、NotificationStore が 1 秒間に平均 50 回も更新されていることが判明しました。

typescript// 改善前:通知が来るたびに即座に状態更新
export const useNotificationStore =
  create<NotificationState>()(
    traceWithSampling(
      (set) => ({
        notifications: [],

        // 問題:個別に更新していた
        addNotification: (notification) => {
          set((state) => ({
            notifications: [
              ...state.notifications,
              createNotification(notification),
            ],
          }));
        },
      }),
      'NotificationStore'
    )
  );
typescript// 改善後:バッチ更新を導入
export const useNotificationStore =
  create<NotificationState>()(
    traceWithSampling((set) => {
      // バッファとタイマー
      let buffer: Array<
        Omit<Notification, 'id' | 'timestamp'>
      > = [];
      let timerId: NodeJS.Timeout | null = null;

      const flushBuffer = () => {
        if (buffer.length === 0) return;

        set((state) => ({
          notifications: [
            ...state.notifications,
            ...buffer.map(createNotification),
          ],
        }));

        buffer = [];
        timerId = null;
      };

      return {
        notifications: [],

        // 100ms以内の通知をまとめて更新
        addNotification: (notification) => {
          buffer.push(notification);

          if (timerId === null) {
            timerId = setTimeout(flushBuffer, 100);
          }
        },
      };
    }, 'NotificationStore')
  );

この改善により、更新頻度が 1 秒間に平均 5 回にまで削減され、パフォーマンスが大幅に向上しました。

事例 2: ユーザーストアの大きな差分サイズ

UserStore で、ユーザー情報の一部だけが変更されるケースでも、毎回オブジェクト全体が更新されていることがわかりました。

typescript// 改善前:オブジェクト全体を更新
setUser: (user) => {
  set({ user }); // user全体を置き換え
};
typescript// 改善後:部分更新メソッドを追加
interface UserState {
  user: {
    id: string;
    name: string;
    email: string;
    preferences: {
      theme: 'light' | 'dark';
      notifications: boolean;
      // ... 他の設定
    };
  } | null;
  setUser: (user: UserState['user']) => void;
  updateUserPreferences: (
    preferences: Partial<UserState['user']['preferences']>
  ) => void;
}
typescript// 部分更新の実装
updateUserPreferences: (preferences) => {
  set((state) => {
    if (!state.user) return state;

    return {
      user: {
        ...state.user,
        preferences: {
          ...state.user.preferences,
          ...preferences,
        },
      },
    };
  });
};

部分更新により、差分サイズが平均 80%削減され、メモリ使用量とレンダリングコストが改善されました。

アラート機能の追加

異常な更新パターンを自動検出し、開発チームに通知するアラート機能も実装できます。

typescript// lib/metrics-alert.ts
interface AlertRule {
  storeName: string;
  metric: 'updateRate' | 'diffSize';
  threshold: number;
  duration: number; // ミリ秒
}

const alertRules: AlertRule[] = [
  {
    storeName: 'NotificationStore',
    metric: 'updateRate',
    threshold: 100, // 1秒間に100回以上
    duration: 5000, // 5秒間継続
  },
  {
    storeName: 'UserStore',
    metric: 'diffSize',
    threshold: 10000, // 10KB以上
    duration: 1000,
  },
];
typescript// アラート判定ロジック
export async function checkAlerts(): Promise<void> {
  for (const rule of alertRules) {
    const metrics = await fetchRecentMetrics(
      rule.storeName,
      rule.duration
    );

    const isViolating = metrics.every((m) => {
      if (rule.metric === 'updateRate') {
        return m.hourlyUpdates / 3600 > rule.threshold;
      } else {
        return m.averageDiffSize > rule.threshold;
      }
    });

    if (isViolating) {
      await sendAlert({
        title: `Zustand メトリクスアラート: ${rule.storeName}`,
        message: `${rule.metric}${rule.threshold} を超えています`,
        severity: 'warning',
      });
    }
  }
}

定期的にこの関数を実行することで、パフォーマンス問題を早期に検出できます。

まとめ

本記事では、Zustand の状態更新を本番環境で計測し、継続的に改善していくための実践的な手法をご紹介しました。

トレースミドルウェアを活用することで、既存のコードを大きく変更することなく、以下の情報を可視化できます。

#可視化できる指標活用方法
1更新頻度不要な更新の検出、バッチ化の検討
2差分サイズ状態設計の見直し、正規化の検討
3ストア別パフォーマンスボトルネックの特定、優先順位付け
4時系列推移改善効果の測定、リグレッション検出

特に重要なのは、計測すること自体が目的ではなく、データに基づいた改善サイクルを回すことです。ダッシュボードで問題を発見し、具体的な最適化を実施し、その効果を再び計測する。このサイクルを継続することで、ユーザー体験を着実に向上させられるでしょう。

また、計測システム自体のパフォーマンスへの影響を最小限に抑えるため、サンプリングやバッチ処理といった工夫も欠かせません。本番環境での安定性を保ちながら、貴重なインサイトを得られる仕組みを構築してください。

Zustand のシンプルさを活かしつつ、エンタープライズグレードの observability を実現する。この記事が、みなさんのアプリケーションのパフォーマンス改善に役立てば幸いです。

関連リンク