T-CREATOR

Jotai 可観測性:ログ/トレース/メトリクスで状態異常を見つける

Jotai 可観測性:ログ/トレース/メトリクスで状態異常を見つける

React アプリケーションで Jotai を使った状態管理を行っていると、「この状態はいつ変更されたのか?」「なぜこの atom が再計算されるのか?」といった疑問に直面することがあります。特に規模が大きくなるにつれて、状態の変更を追跡し、パフォーマンスの問題を特定することが難しくなってきますよね。

本記事では、Jotai の可観測性を高めるための実践的な手法をご紹介します。ログ、トレース、メトリクスという 3 つの柱を活用することで、状態異常を素早く発見し、デバッグ時間を大幅に短縮できるようになるでしょう。

背景

Jotai における状態管理の特徴

Jotai は atom という小さな状態の単位を組み合わせて、複雑な状態管理を実現する React 向けの状態管理ライブラリです。Redux のような中央集権的なアプローチとは異なり、分散型の設計を採用しているため、必要な部分だけを再レンダリングできる優れた特性を持っています。

しかし、この分散型の設計は可観測性の観点では課題も生み出します。atom 同士の依存関係が複雑になると、どの atom がどのタイミングで更新されるのか把握しづらくなってしまうのです。

Jotai の atom 依存関係の可視化

以下の図は、複数の atom が相互に依存している様子を表しています。この依存グラフを理解することが、可観測性の第一歩となります。

mermaidflowchart TD
  userAtom["userAtom<br/>(ユーザー情報)"]
  settingsAtom["settingsAtom<br/>(設定情報)"]
  themeAtom["themeAtom<br/>(テーマ)"]
  displayAtom["displayAtom<br/>(表示用データ)"]

  userAtom --> displayAtom
  settingsAtom --> themeAtom
  settingsAtom --> displayAtom
  themeAtom --> displayAtom

この図から分かるように、displayAtom は複数の atom に依存しており、いずれかが変更されると再計算が発生します。可観測性がないと、なぜ displayAtom が更新されたのか特定するのに時間がかかってしまうでしょう。

課題

状態管理における 3 つの主要な課題

Jotai を使った開発で直面する課題は、主に以下の 3 つに分類できます。

1. デバッグの困難さ

atom の値がいつ、どこで変更されたのかを追跡するのは容易ではありません。特に非同期処理を含む atom では、更新のタイミングが予測しづらく、バグの原因特定に多くの時間を費やしてしまうことがあります。

2. パフォーマンスの問題

不要な再レンダリングや、過度な atom の再計算はアプリケーションのパフォーマンスを低下させます。しかし、どの atom がボトルネックになっているのか、メトリクスがなければ推測するしかありません。

3. 状態の整合性

複数の atom が連携して動作する場合、状態の整合性を保つことが重要です。しかし、どの順序で atom が更新されるのか、トレース情報がないと検証が難しくなってしまいますね。

#課題影響検出の難易度
1デバッグの困難さ開発速度の低下★★★
2パフォーマンス問題ユーザー体験の悪化★★★★
3状態の整合性バグの発生★★★★★

これらの課題を解決するためには、システムの内部状態を可視化する「可観測性」の仕組みが不可欠です。

解決策

可観測性の 3 つの柱

可観測性を実現するには、ログ、トレース、メトリクスという 3 つの要素を組み合わせることが効果的です。以下の図で、それぞれの役割と関係性を確認してみましょう。

mermaidflowchart LR
  atom["Atom の状態変更"]

  subgraph observability["可観測性の 3 つの柱"]
    logs["ログ<br/>(何が起きたか)"]
    traces["トレース<br/>(どう伝播したか)"]
    metrics["メトリクス<br/>(どれくらいか)"]
  end

  debug["デバッグ<br/>問題の特定"]

  atom --> logs
  atom --> traces
  atom --> metrics

  logs --> debug
  traces --> debug
  metrics --> debug

それぞれの柱について、Jotai での実装方法を見ていきましょう。

ログ:状態変更の記録

ログは「何が起きたか」を記録する仕組みです。Jotai では、atom の読み取りや書き込みをフックして、変更履歴を残すことができます。

カスタムフックでのログ実装

まず、atom の変更をログに記録するカスタムフックを作成します。

typescriptimport { useEffect } from 'react';
import { atom, useAtom } from 'jotai';

// ログ出力用のユーティリティ関数
const logAtomChange = (
  atomName: string,
  oldValue: unknown,
  newValue: unknown
) => {
  console.log(`[Jotai Log] ${atomName}:`, {
    timestamp: new Date().toISOString(),
    oldValue,
    newValue,
    changed: oldValue !== newValue,
  });
};

このユーティリティ関数は、atom 名、変更前の値、変更後の値をタイムスタンプ付きでログに出力します。

ログ機能付き atom の作成

次に、自動的にログを出力する atom を作成するヘルパー関数を実装しましょう。

typescriptimport { atom, WritableAtom } from 'jotai';

// ログ機能を持つ atom を作成するヘルパー
export const atomWithLog = <T>(
  name: string,
  initialValue: T
): WritableAtom<T, [T], void> => {
  const baseAtom = atom(initialValue);

  return atom(
    (get) => get(baseAtom),
    (get, set, newValue: T) => {
      const oldValue = get(baseAtom);
      logAtomChange(name, oldValue, newValue);
      set(baseAtom, newValue);
    }
  );
};

この関数を使うことで、すべての書き込み操作が自動的にログに記録されるようになります。

トレース:依存関係の追跡

トレースは「状態変更がどう伝播したか」を記録する仕組みです。atom 間の依存関係を追跡することで、連鎖的な更新を可視化できます。

トレース ID の生成

まず、各状態変更に一意の ID を付与する仕組みを作ります。

typescript// トレース ID を生成する関数
let traceIdCounter = 0;

const generateTraceId = (): string => {
  traceIdCounter++;
  return `trace-${Date.now()}-${traceIdCounter}`;
};

依存関係の記録

atom の読み取り時に、依存関係をトレースとして記録します。

typescripttype TraceRecord = {
  traceId: string;
  atomName: string;
  dependencies: string[];
  timestamp: number;
};

const traceRecords: TraceRecord[] = [];

export const atomWithTrace = <T>(
  name: string,
  initialValue: T
): WritableAtom<T, [T], void> => {
  const baseAtom = atom(initialValue);

  return atom(
    (get) => {
      const traceId = generateTraceId();
      // 依存する atom を記録(簡略化のため省略)
      traceRecords.push({
        traceId,
        atomName: name,
        dependencies: [],
        timestamp: Date.now(),
      });
      return get(baseAtom);
    },
    (get, set, newValue: T) => {
      set(baseAtom, newValue);
    }
  );
};

これにより、atom がいつ読み取られたかの履歴が残り、依存関係の追跡が可能になります。

メトリクス:パフォーマンスの測定

メトリクスは「どれくらいの頻度・時間がかかったか」を数値化する仕組みです。atom の更新回数や計算時間を測定することで、パフォーマンスのボトルネックを特定できます。

メトリクス収集の実装

atom の読み取り・書き込み回数、および処理時間を記録します。

typescripttype AtomMetrics = {
  readCount: number;
  writeCount: number;
  totalReadTime: number;
  totalWriteTime: number;
  lastAccessed: number;
};

const metricsMap = new Map<string, AtomMetrics>();

// メトリクスの初期化
const initMetrics = (atomName: string): void => {
  if (!metricsMap.has(atomName)) {
    metricsMap.set(atomName, {
      readCount: 0,
      writeCount: 0,
      totalReadTime: 0,
      totalWriteTime: 0,
      lastAccessed: Date.now(),
    });
  }
};

メトリクス測定機能付き atom

実際の処理時間を測定する atom を作成します。

typescriptexport const atomWithMetrics = <T>(
  name: string,
  initialValue: T
): WritableAtom<T, [T], void> => {
  initMetrics(name);
  const baseAtom = atom(initialValue);

  return atom(
    (get) => {
      const startTime = performance.now();
      const value = get(baseAtom);
      const endTime = performance.now();

      const metrics = metricsMap.get(name)!;
      metrics.readCount++;
      metrics.totalReadTime += endTime - startTime;
      metrics.lastAccessed = Date.now();

      return value;
    },
    (get, set, newValue: T) => {
      const startTime = performance.now();
      set(baseAtom, newValue);
      const endTime = performance.now();

      const metrics = metricsMap.get(name)!;
      metrics.writeCount++;
      metrics.totalWriteTime += endTime - startTime;
      metrics.lastAccessed = Date.now();
    }
  );
};

これで各 atom の使用状況とパフォーマンスが数値で把握できるようになりました。

メトリクスのレポート出力

収集したメトリクスを分析しやすい形式で出力する関数も用意しましょう。

typescript// メトリクスレポートの生成
export const generateMetricsReport = (): void => {
  console.table(
    Array.from(metricsMap.entries()).map(
      ([name, metrics]) => ({
        Atom: name,
        読取回数: metrics.readCount,
        書込回数: metrics.writeCount,
        平均読取時間:
          (
            metrics.totalReadTime / metrics.readCount
          ).toFixed(3) + 'ms',
        平均書込時間:
          (
            metrics.totalWriteTime / metrics.writeCount
          ).toFixed(3) + 'ms',
        最終アクセス: new Date(
          metrics.lastAccessed
        ).toLocaleTimeString(),
      })
    )
  );
};

この関数を呼び出すと、すべての atom のパフォーマンスメトリクスが一覧表示されます。

具体例

実践的な可観測性の実装

ここでは、ユーザー情報を管理するアプリケーションを例に、ログ・トレース・メトリクスを統合した実装を見ていきましょう。

可観測性を備えた atom の定義

まず、すべての機能を統合した atom 作成関数を実装します。

typescriptimport { atom, WritableAtom } from 'jotai';

// ログ、トレース、メトリクスをすべて備えた atom
export const atomWithObservability = <T>(
  name: string,
  initialValue: T
): WritableAtom<T, [T], void> => {
  // メトリクスの初期化
  initMetrics(name);

  const baseAtom = atom(initialValue);

  return atom(
    (get) => {
      // トレースID の生成
      const traceId = generateTraceId();

      // メトリクス測定開始
      const startTime = performance.now();
      const value = get(baseAtom);
      const endTime = performance.now();

      // メトリクス更新
      const metrics = metricsMap.get(name)!;
      metrics.readCount++;
      metrics.totalReadTime += endTime - startTime;

      // トレース記録
      console.log(`[Trace ${traceId}] Read: ${name}`);

      return value;
    },
    (get, set, newValue: T) => {
      const oldValue = get(baseAtom);

      // メトリクス測定開始
      const startTime = performance.now();
      set(baseAtom, newValue);
      const endTime = performance.now();

      // ログ出力
      logAtomChange(name, oldValue, newValue);

      // メトリクス更新
      const metrics = metricsMap.get(name)!;
      metrics.writeCount++;
      metrics.totalWriteTime += endTime - startTime;
    }
  );
};

ユーザー管理アプリケーションでの使用例

実際のアプリケーションでこの atom を使ってみましょう。

typescriptimport { atomWithObservability } from './observability';

// ユーザー情報の型定義
type User = {
  id: string;
  name: string;
  email: string;
};

// 可観測性を持つユーザー atom
export const userAtom = atomWithObservability<User | null>(
  'userAtom',
  null
);

設定情報やテーマも同様に定義します。

typescript// 設定情報の型定義
type Settings = {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
  notifications: boolean;
};

// 可観測性を持つ設定 atom
export const settingsAtom = atomWithObservability<Settings>(
  'settingsAtom',
  {
    theme: 'light',
    language: 'ja',
    notifications: true,
  }
);

派生 atom での可観測性

複数の atom に依存する派生 atom も可観測にしてみましょう。

typescriptimport { atom } from 'jotai';

// 表示用データを生成する派生 atom
export const displayAtom = atom((get) => {
  const user = get(userAtom);
  const settings = get(settingsAtom);

  // 依存関係のログ出力
  console.log('[Dependency] displayAtom depends on:', {
    user: user?.name,
    theme: settings.theme,
  });

  if (!user) {
    return {
      message: 'ログインしてください',
      theme: settings.theme,
    };
  }

  return {
    message: `こんにちは、${user.name}さん`,
    theme: settings.theme,
  };
});

以下の図は、状態変更がどのように伝播するかを示しています。

mermaidsequenceDiagram
  participant User as ユーザー操作
  participant UA as userAtom
  participant DA as displayAtom
  participant Log as ログシステム
  participant UI as UI再レンダリング

  User->>UA: ユーザー情報を更新
  UA->>Log: [ログ] 値の変更を記録
  UA->>DA: 依存関係により再計算
  DA->>Log: [トレース] 依存を記録
  DA->>UI: 新しい表示データ
  UI->>User: 画面更新

この図から、ユーザー操作から UI 更新までの流れと、各ステップでのログ・トレースの記録タイミングが理解できます。

React コンポーネントでの利用

実際の React コンポーネントで使用する例を見てみましょう。

typescriptimport { useAtom, useAtomValue } from 'jotai';
import { userAtom, displayAtom } from './atoms';

export const UserProfile = () => {
  const [user, setUser] = useAtom(userAtom);
  const display = useAtomValue(displayAtom);

  const handleLogin = () => {
    // ユーザー情報の更新(自動的にログが記録される)
    setUser({
      id: '001',
      name: '山田太郎',
      email: 'yamada@example.com',
    });
  };

  return (
    <div>
      <p>{display.message}</p>
      <button onClick={handleLogin}>ログイン</button>
    </div>
  );
};

このコンポーネントを使用すると、ログインボタンをクリックした際に以下のようなログが出力されます。

typescript// コンソール出力例(実際の出力イメージ)
/*
[Jotai Log] userAtom: {
  timestamp: "2025-11-26T10:30:45.123Z",
  oldValue: null,
  newValue: { id: "001", name: "山田太郎", email: "yamada@example.com" },
  changed: true
}
[Dependency] displayAtom depends on: {
  user: "山田太郎",
  theme: "light"
}
*/

デバッグツールの作成

開発時に便利なデバッグパネルコンポーネントも作成してみましょう。

typescriptimport { useEffect, useState } from 'react';
import { generateMetricsReport } from './observability';

export const DebugPanel = () => {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    // 5秒ごとにメトリクスレポートを出力
    const interval = setInterval(() => {
      if (isOpen) {
        generateMetricsReport();
      }
    }, 5000);

    return () => clearInterval(interval);
  }, [isOpen]);

  return (
    <div
      style={{
        position: 'fixed',
        bottom: 20,
        right: 20,
        padding: 10,
        background: '#f0f0f0',
        borderRadius: 8,
      }}
    >
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen
          ? 'デバッグパネルを閉じる'
          : 'デバッグパネルを開く'}
      </button>
      {isOpen && (
        <div>
          <p>メトリクスはコンソールで確認できます</p>
          <button onClick={generateMetricsReport}>
            今すぐレポート出力
          </button>
        </div>
      )}
    </div>
  );
};

このデバッグパネルを開発環境でのみ表示させることで、リアルタイムでパフォーマンスを監視できます。

環境に応じた可観測性の有効化

本番環境ではパフォーマンスへの影響を避けるため、可観測性機能を無効化する実装も重要です。

typescript// 環境変数で可観測性を制御
const isObservabilityEnabled =
  process.env.NODE_ENV === 'development' ||
  process.env.ENABLE_OBSERVABILITY === 'true';

export const atomWithConditionalObservability = <T>(
  name: string,
  initialValue: T
): WritableAtom<T, [T], void> => {
  if (!isObservabilityEnabled) {
    // 本番環境では通常の atom を返す
    return atom(initialValue);
  }

  // 開発環境では可観測性付き atom を返す
  return atomWithObservability(name, initialValue);
};

この実装により、開発時のデバッグ効率を高めつつ、本番環境でのパフォーマンスも確保できます。

エラー検知とアラート

状態の異常を自動検知する仕組みも追加してみましょう。

typescript// 異常検知のための閾値設定
type AlertThresholds = {
  maxReadCount: number;
  maxWriteCount: number;
  maxAvgReadTime: number;
  maxAvgWriteTime: number;
};

const defaultThresholds: AlertThresholds = {
  maxReadCount: 1000,
  maxWriteCount: 100,
  maxAvgReadTime: 10, // ミリ秒
  maxAvgWriteTime: 5,
};

閾値を超えた場合にアラートを出す関数を実装します。

typescript// メトリクスの異常をチェック
export const checkMetricsHealth = (
  thresholds: AlertThresholds = defaultThresholds
): void => {
  metricsMap.forEach((metrics, atomName) => {
    const avgReadTime =
      metrics.totalReadTime / metrics.readCount;
    const avgWriteTime =
      metrics.totalWriteTime / metrics.writeCount;

    // 読み取り回数が多すぎる場合
    if (metrics.readCount > thresholds.maxReadCount) {
      console.warn(
        `[Alert] ${atomName}: 読み取り回数が多すぎます (${metrics.readCount}回)`
      );
    }

    // 平均処理時間が長すぎる場合
    if (avgReadTime > thresholds.maxAvgReadTime) {
      console.warn(
        `[Alert] ${atomName}: 平均読み取り時間が長すぎます (${avgReadTime.toFixed(
          2
        )}ms)`
      );
    }
  });
};

定期的にこの関数を実行することで、パフォーマンス劣化を早期に発見できるようになります。

まとめ

Jotai での状態管理に可観測性を導入することで、開発効率とアプリケーションの品質が大きく向上します。本記事でご紹介した 3 つの柱、ログ・トレース・メトリクスを活用することで、以下のメリットが得られるでしょう。

ログによる変更履歴の追跡では、いつ・どの atom が・どのように変更されたかを時系列で確認できます。デバッグ時に変更の原因を素早く特定できるようになりますね。

トレースによる依存関係の可視化では、atom 間の連鎖的な更新を追跡できます。複雑な状態管理でも、どの atom がどの順序で更新されるのか明確になるでしょう。

メトリクスによるパフォーマンス測定では、各 atom の使用頻度や処理時間を数値化できます。ボトルネックを客観的なデータで特定し、最適化の優先順位を付けられます。

これらの仕組みを環境に応じて有効化することで、開発時のデバッグ効率を高めながら、本番環境でのパフォーマンスも維持できます。

可観測性は一度実装すれば、その後の開発・運用を通じて継続的に価値を提供してくれるでしょう。ぜひ皆さんのプロジェクトでも、Jotai の可観測性を高める取り組みを始めてみてください。状態管理の透明性が向上し、より安心してアプリケーションを成長させられるようになりますよ。

関連リンク