T-CREATOR

jotai IndexedDB・localForage と連携した大容量永続化パターン(atom を超えて)

jotai IndexedDB・localForage と連携した大容量永続化パターン(atom を超えて)

React の状態管理ライブラリとして注目を集める jotai ですが、大容量データを扱う現代の Web アプリケーション開発では、標準的な atom による状態管理だけでは限界があることをご存じでしょうか。本記事では、jotai の軽量性を保ちながら、IndexedDB や localForage との連携により大容量データの永続化を実現する実践的な手法をご紹介いたします。

複雑なデータ管理が必要なアプリケーションでも、パフォーマンスを犠牲にすることなく効率的な状態管理を実現できるでしょう。

背景

jotai の標準的な状態管理の制約

jotai は React の状態管理における革新的なアプローチとして、atom ベースの細粒度な状態管理を提供しています。従来の状態管理ライブラリと比較して、非常にシンプルで直感的な API が特徴的ですね。

typescriptimport { atom } from 'jotai';

// 基本的な atom の定義
const countAtom = atom(0);
const userAtom = atom({ name: '', email: '' });

しかし標準的な atom は、基本的にメモリ内での状態管理を前提としており、ブラウザリフレッシュ時には状態が失われてしまいます。また、大容量データを atom で管理する場合、以下のような制約が生じることになります。

typescript// 大量のデータを atom で管理する場合の問題
const largeDataAtom = atom(
  Array(10000)
    .fill(null)
    .map((_, i) => ({
      id: i,
      data: 'large data content...',
      timestamp: Date.now(),
    }))
); // メモリを大量消費

大容量データ管理における課題

現代の Web アプリケーションでは、ユーザーが生成するコンテンツ、画像、ドキュメント、設定情報など、様々な大容量データを扱う必要があります。特にオフライン対応や Progressive Web App(PWA)の実装においては、これらのデータをクライアント側で効率的に永続化する必要性が高まっているのです。

以下の図で、従来の atom による状態管理での課題を整理してみましょう。

mermaidflowchart TD
  app[React App] -->|大容量データ| atom[jotai atom]
  atom -->|メモリ使用量増大| memory[メモリ制約]
  atom -->|リフレッシュ時| lost[データ消失]
  atom -->|同期処理| blocking[UI ブロッキング]

  memory --> problem1[パフォーマンス低下]
  lost --> problem2[ユーザー体験悪化]
  blocking --> problem3[レスポンシブ性の低下]

これらの課題により、atom 単体では大容量データの管理に限界があることがわかります。

IndexedDB・localForage の特徴と利点

IndexedDB はブラウザが提供する NoSQL データベースで、大容量データの永続化に適した技術です。一方、localForage は IndexedDB、WebSQL、localStorage を統一的な API で利用できるライブラリとして広く活用されています。

IndexedDB の主要な特徴をご紹介いたします。

| 特徴 | IndexedDB | localStorage | | ---- | ---------------- | --------------------- | ---------- | | # | 容量制限 | 数 GB ~数十 GB | 5-10MB | | # | データ型 | オブジェクト、Blob 等 | 文字列のみ | | # | 処理方式 | 非同期 | 同期 | | # | トランザクション | 対応 | 未対応 |

localForage を使用することで、複雑な IndexedDB API を抽象化し、開発効率を向上させることができますね。

typescriptimport localForage from 'localforage';

// localForage の基本設定
localForage.config({
  driver: localForage.INDEXEDDB,
  name: 'myApp',
  version: 1.0,
  storeName: 'userData',
});

課題

atom 単体での大容量データ永続化の限界

jotai の atom は優れた状態管理機能を提供していますが、大容量データの永続化においては以下のような制約があることを理解しておく必要があります。

まず、データ永続化の観点から見た制約を整理してみましょう。

typescriptimport { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// localStorage を使用した永続化の制約例
const largeImageDataAtom = atomWithStorage('images', []);

// 問題:大容量画像データをlocalStorageに保存しようとする
const handleImageUpload = (imageData: string) => {
  // Base64エンコードされた画像データは容量が大きく
  // localStorageの制限(通常5-10MB)を超える可能性
  setImageData((prev) => [...prev, imageData]);
};

localStorage ベースの atomWithStorage では、容量制限により大容量データの保存ができません。また、同期的な処理のため、大量データの読み書き時に UI がブロックされる問題も発生いたします。

メモリ効率とパフォーマンスの問題

大容量データを atom で管理する場合、メモリ使用量の増大によるパフォーマンスの問題が顕著に現れます。以下の図でメモリ使用パターンを可視化してみましょう。

mermaidgraph LR
  A[小規模データ] -->|atom| B[メモリ使用量: 少]
  C[中規模データ] -->|atom| D[メモリ使用量: 中]
  E[大容量データ] -->|atom| F[メモリ使用量: 大]

  F --> G[ガベージコレクション頻発]
  G --> H[パフォーマンス低下]
  H --> I[ユーザー体験悪化]

特に以下のようなシナリオでは、メモリ効率の問題が深刻化いたします。

typescript// 問題のあるパターン:大容量データの一括管理
const largeDatasetAtom = atom([]);

// 数千~数万件のデータを一度にメモリに展開
const loadLargeDataset = async () => {
  const data = await fetchLargeDataset(); // 数十MB のデータ
  setLargeDataset(data); // メモリに全て展開される
};

ブラウザストレージ API の複雑性

IndexedDB を直接使用する場合、API の複雑性により開発コストが増大する課題があります。トランザクション管理、エラーハンドリング、非同期処理の制御など、多くの考慮事項が必要になるためです。

typescript// IndexedDB の複雑な直接操作例
const saveToIndexedDB = (data: any): Promise<void> => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('myDB', 1);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => {
      const db = request.result;
      const transaction = db.transaction(
        ['store'],
        'readwrite'
      );
      const store = transaction.objectStore('store');

      const addRequest = store.add(data);
      addRequest.onerror = () => reject(addRequest.error);
      addRequest.onsuccess = () => resolve();
    };
  });
};

このような複雑性により、開発効率が低下し、バグが発生しやすくなる問題があります。

解決策

jotai と IndexedDB の連携アーキテクチャ

jotai と IndexedDB を連携させる効果的なアーキテクチャとして、階層化されたデータ管理パターンをご提案いたします。この方法により、メモリ効率とパフォーマンスを両立できるでしょう。

以下の図で、提案するアーキテクチャの全体像を示します。

mermaidflowchart TB
  ui[React UI] --> cache[jotai Cache Layer]
  cache --> sync[Sync Manager]
  sync --> idb[(IndexedDB)]

  cache --> atom1[Active Data Atoms]
  cache --> atom2[Metadata Atoms]

  sync --> loader[Lazy Loader]
  sync --> persister[Background Persister]

  loader --> idb
  persister --> idb

このアーキテクチャでは、jotai をキャッシュレイヤーとして活用し、実際のデータ永続化を IndexedDB で行う分離設計を採用しています。

基本的な連携パターンの実装をご紹介いたします。

typescriptimport { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// IndexedDB接続の管理用atom
const dbAtom = atom(async () => {
  return new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open('jotaiApp', 1);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains('data')) {
        db.createObjectStore('data', { keyPath: 'id' });
      }
    };
  });
});

localForage を活用した抽象化レイヤー

localForage を活用することで、IndexedDB の複雑性を隠蔽し、jotai との統合を簡素化できます。カスタムストレージプロバイダーを実装して、atomWithStorage との連携を実現してみましょう。

typescriptimport localForage from 'localforage';

// localForage設定
const dataStore = localForage.createInstance({
  name: 'jotai-app',
  storeName: 'largeData',
});

// カスタムストレージプロバイダー
const createLocalForageStorage = () => ({
  getItem: async (key: string) => {
    try {
      return await dataStore.getItem(key);
    } catch {
      return null;
    }
  },
  setItem: async (key: string, value: any) => {
    await dataStore.setItem(key, value);
  },
  removeItem: async (key: string) => {
    await dataStore.removeItem(key);
  },
});

このカスタムストレージを使用して、大容量データ対応の atom を作成できます。

typescript// 大容量データ対応atom
const largeDataAtom = atomWithStorage(
  'largeData',
  [],
  createLocalForageStorage()
);

効率的なデータ同期パターン

データの同期効率を最大化するため、遅延読み込みとバックグラウンド永続化を組み合わせたパターンを実装いたします。これにより、UI の応答性を保ちながら大容量データを管理できるでしょう。

typescriptimport { atom } from 'jotai';

// キャッシュ状態管理
const cacheAtom = atom(new Map());
const loadingAtom = atom(new Set());

// 遅延読み込み対応atom
const createLazyAtom = (key: string) => {
  return atom(
    async (get) => {
      const cache = get(cacheAtom);
      const loading = get(loadingAtom);

      // キャッシュから取得を試行
      if (cache.has(key)) {
        return cache.get(key);
      }

      // 既に読み込み中の場合は待機
      if (loading.has(key)) {
        return null;
      }

      // IndexedDBから非同期読み込み
      loading.add(key);
      try {
        const data = await dataStore.getItem(key);
        cache.set(key, data);
        return data;
      } finally {
        loading.delete(key);
      }
    },
    async (get, set, value) => {
      // キャッシュ更新
      const cache = get(cacheAtom);
      cache.set(key, value);
      set(cacheAtom, new Map(cache));

      // バックグラウンドで永続化
      setTimeout(() => {
        dataStore.setItem(key, value);
      }, 0);
    }
  );
};

このパターンにより、同期的な UI 更新と非同期的なデータ永続化を分離し、パフォーマンスの最適化を実現しています。

具体例

基本的な IndexedDB 連携 atom の実装

実際のプロジェクトで使用可能な、IndexedDB 連携 atom の完全な実装例をご紹介いたします。エラーハンドリングやタイプセーフティも考慮した実用的なコードです。

まず、IndexedDB 操作の基盤となるユーティリティを実装いたします。

typescript// types.ts
export interface DataItem {
  id: string;
  content: any;
  timestamp: number;
  metadata?: Record<string, any>;
}

export interface DBConfig {
  dbName: string;
  storeName: string;
  version: number;
}
typescript// indexeddb-utils.ts
import { DBConfig, DataItem } from './types';

export class IndexedDBManager {
  private db: IDBDatabase | null = null;

  constructor(private config: DBConfig) {}

  async initialize(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(
        this.config.dbName,
        this.config.version
      );

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };

      request.onupgradeneeded = () => {
        const db = request.result;
        if (
          !db.objectStoreNames.contains(
            this.config.storeName
          )
        ) {
          const store = db.createObjectStore(
            this.config.storeName,
            { keyPath: 'id' }
          );
          store.createIndex('timestamp', 'timestamp');
        }
      };
    });
  }

  async save(item: DataItem): Promise<void> {
    if (!this.db) throw new Error('DB not initialized');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction(
        [this.config.storeName],
        'readwrite'
      );
      const store = transaction.objectStore(
        this.config.storeName
      );
      const request = store.put(item);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve();
    });
  }

  async load(id: string): Promise<DataItem | null> {
    if (!this.db) throw new Error('DB not initialized');

    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction(
        [this.config.storeName],
        'readonly'
      );
      const store = transaction.objectStore(
        this.config.storeName
      );
      const request = store.get(id);

      request.onerror = () => reject(request.error);
      request.onsuccess = () =>
        resolve(request.result || null);
    });
  }
}

次に、この IndexedDB マネージャーを使用した atom の実装です。

typescript// indexed-atom.ts
import { atom } from 'jotai';
import {
  IndexedDBManager,
  DataItem,
} from './indexeddb-utils';

const dbManager = new IndexedDBManager({
  dbName: 'jotaiApp',
  storeName: 'atomData',
  version: 1,
});

// DB初期化の管理
const dbInitAtom = atom(async () => {
  await dbManager.initialize();
  return true;
});

// IndexedDB連携atomのファクトリー
export const createIndexedAtom = <T>(
  key: string,
  defaultValue: T
) => {
  const baseAtom = atom<T>(defaultValue);

  return atom(
    async (get) => {
      // DB初期化を待つ
      await get(dbInitAtom);

      try {
        const item = await dbManager.load(key);
        return item ? item.content : get(baseAtom);
      } catch (error) {
        console.warn(`Failed to load ${key}:`, error);
        return get(baseAtom);
      }
    },
    async (get, set, update: T) => {
      // まずメモリ上の状態を更新
      set(baseAtom, update);

      // バックグラウンドでIndexedDBに保存
      try {
        const item: DataItem = {
          id: key,
          content: update,
          timestamp: Date.now(),
        };
        await dbManager.save(item);
      } catch (error) {
        console.error(`Failed to save ${key}:`, error);
      }
    }
  );
};

localForage 統合による実装パターン

localForage を使用したより簡潔な実装パターンをご紹介いたします。こちらの方法では、複雑な IndexedDB API を意識することなく、大容量データの永続化を実現できます。

typescript// localforage-atom.ts
import localForage from 'localforage';
import { atom } from 'jotai';

// 設定可能なlocalForageインスタンス
const createDataStore = (name: string) =>
  localForage.createInstance({
    driver: localForage.INDEXEDDB,
    name: 'jotai-app',
    storeName: name,
    description: 'Jotai large data storage',
  });

// 汎用的なlocalForage atom作成関数
export const createLocalForageAtom = <T>(
  storeName: string,
  key: string,
  defaultValue: T,
  options: {
    debounceMs?: number;
    transform?: {
      serialize?: (value: T) => any;
      deserialize?: (value: any) => T;
    };
  } = {}
) => {
  const store = createDataStore(storeName);
  const { debounceMs = 500, transform } = options;

  let saveTimeout: NodeJS.Timeout | null = null;

  const baseAtom = atom<T>(defaultValue);
  const loadingAtom = atom<boolean>(false);

  return atom(
    async (get) => {
      if (get(loadingAtom)) {
        return get(baseAtom);
      }

      try {
        const stored = await store.getItem<any>(key);
        if (stored !== null) {
          const value = transform?.deserialize
            ? transform.deserialize(stored)
            : stored;
          return value;
        }
      } catch (error) {
        console.warn(
          `Failed to load from localForage:`,
          error
        );
      }

      return defaultValue;
    },
    async (get, set, update: T) => {
      // 即座にメモリ状態を更新
      set(baseAtom, update);

      // デバウンス付きの永続化
      if (saveTimeout) {
        clearTimeout(saveTimeout);
      }

      saveTimeout = setTimeout(async () => {
        try {
          const valueToStore = transform?.serialize
            ? transform.serialize(update)
            : update;
          await store.setItem(key, valueToStore);
        } catch (error) {
          console.error(
            `Failed to save to localForage:`,
            error
          );
        }
      }, debounceMs);
    }
  );
};

使用例として、ユーザー設定の永続化を実装してみましょう。

typescript// user-settings.ts
interface UserSettings {
  theme: 'light' | 'dark';
  language: string;
  preferences: Record<string, any>;
}

const userSettingsAtom =
  createLocalForageAtom<UserSettings>(
    'settings',
    'userSettings',
    {
      theme: 'light',
      language: 'ja',
      preferences: {},
    },
    {
      debounceMs: 1000, // 1秒でデバウンス
      transform: {
        serialize: (settings) => ({
          ...settings,
          savedAt: Date.now(),
        }),
        deserialize: (stored) => {
          const { savedAt, ...settings } = stored;
          return settings;
        },
      },
    }
  );

大容量画像・ファイル管理の実装例

大容量のバイナリデータ(画像、ファイル)を効率的に管理する実装例をご紹介いたします。Blob API と組み合わせることで、メモリ効率的なファイル管理が可能になります。

typescript// file-storage-atom.ts
interface FileData {
  id: string;
  name: string;
  type: string;
  size: number;
  blob: Blob;
  thumbnail?: Blob;
  uploadedAt: number;
}

interface FileMetadata {
  id: string;
  name: string;
  type: string;
  size: number;
  thumbnailUrl?: string;
  uploadedAt: number;
}

// ファイルメタデータのみをメモリで管理
const fileMetadataAtom = createLocalForageAtom<
  FileMetadata[]
>('files', 'metadata', []);
typescript// ファイル操作用のatom
const fileManagerAtom = atom(
  null,
  async (
    get,
    set,
    action: {
      type: 'add' | 'remove' | 'get';
      payload: any;
    }
  ) => {
    const fileStore = createDataStore('fileBlobs');
    const metadata = get(fileMetadataAtom);

    switch (action.type) {
      case 'add':
        const { file, generateThumbnail } =
          action.payload as {
            file: File;
            generateThumbnail?: boolean;
          };

        const fileId = crypto.randomUUID();
        const fileData: FileData = {
          id: fileId,
          name: file.name,
          type: file.type,
          size: file.size,
          blob: file,
          uploadedAt: Date.now(),
        };

        // サムネイル生成(画像の場合)
        if (
          generateThumbnail &&
          file.type.startsWith('image/')
        ) {
          fileData.thumbnail = await generateImageThumbnail(
            file
          );
        }

        // Blobデータを保存
        await fileStore.setItem(fileId, fileData);

        // メタデータを更新
        const newMetadata: FileMetadata = {
          id: fileId,
          name: file.name,
          type: file.type,
          size: file.size,
          uploadedAt: fileData.uploadedAt,
          thumbnailUrl: fileData.thumbnail
            ? URL.createObjectURL(fileData.thumbnail)
            : undefined,
        };

        set(fileMetadataAtom, [...metadata, newMetadata]);
        return fileId;

      case 'get':
        const { fileId } = action.payload;
        const fileData = await fileStore.getItem<FileData>(
          fileId
        );
        return fileData;

      case 'remove':
        const { fileId: idToRemove } = action.payload;
        await fileStore.removeItem(idToRemove);
        set(
          fileMetadataAtom,
          metadata.filter((item) => item.id !== idToRemove)
        );
        break;
    }
  }
);
typescript// サムネイル生成ユーティリティ
async function generateImageThumbnail(
  file: File,
  maxSize: number = 200
): Promise<Blob> {
  return new Promise((resolve) => {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d')!;
    const img = new Image();

    img.onload = () => {
      const { width, height } = img;
      const scale = Math.min(
        maxSize / width,
        maxSize / height
      );

      canvas.width = width * scale;
      canvas.height = height * scale;

      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      canvas.toBlob(resolve!, 'image/jpeg', 0.7);
    };

    img.src = URL.createObjectURL(file);
  });
}

React コンポーネントでの使用例です。

tsx// FileUpload.tsx
import React, { useCallback } from 'react';
import { useAtom, useAtomValue } from 'jotai';

const FileUpload: React.FC = () => {
  const [, manageFile] = useAtom(fileManagerAtom);
  const metadata = useAtomValue(fileMetadataAtom);

  const handleFileUpload = useCallback(
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      const files = event.target.files;
      if (!files) return;

      for (const file of Array.from(files)) {
        await manageFile({
          type: 'add',
          payload: {
            file,
            generateThumbnail:
              file.type.startsWith('image/'),
          },
        });
      }
    },
    [manageFile]
  );

  const handleFileDownload = useCallback(
    async (fileId: string) => {
      const fileData = (await manageFile({
        type: 'get',
        payload: { fileId },
      })) as FileData;

      if (fileData) {
        const url = URL.createObjectURL(fileData.blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = fileData.name;
        a.click();
        URL.revokeObjectURL(url);
      }
    },
    [manageFile]
  );

  return (
    <div>
      <input
        type='file'
        multiple
        onChange={handleFileUpload}
        accept='image/*,.pdf,.docx'
      />

      <div className='file-list'>
        {metadata.map((file) => (
          <div key={file.id} className='file-item'>
            {file.thumbnailUrl && (
              <img
                src={file.thumbnailUrl}
                alt={file.name}
                className='thumbnail'
              />
            )}
            <span>{file.name}</span>
            <span>
              ({(file.size / 1024).toFixed(1)} KB)
            </span>
            <button
              onClick={() => handleFileDownload(file.id)}
            >
              ダウンロード
            </button>
          </div>
        ))}
      </div>
    </div>
  );
};

リアルタイム同期機能の実装

複数タブ間でのリアルタイム同期機能を実装し、一貫したユーザー体験を提供する方法をご説明いたします。BroadcastChannel API を活用した実装例です。

typescript// sync-manager.ts
interface SyncMessage {
  type: 'update' | 'invalidate' | 'ping';
  atomKey: string;
  data?: any;
  timestamp: number;
  tabId: string;
}

class SyncManager {
  private channel: BroadcastChannel;
  private tabId: string;
  private listeners = new Map<
    string,
    Set<(data: any) => void>
  >();

  constructor() {
    this.tabId = crypto.randomUUID();
    this.channel = new BroadcastChannel('jotai-sync');
    this.setupListeners();
  }

  private setupListeners() {
    this.channel.addEventListener('message', (event) => {
      const message: SyncMessage = event.data;

      // 自分が送信したメッセージは無視
      if (message.tabId === this.tabId) return;

      const listeners = this.listeners.get(message.atomKey);
      if (listeners && message.type === 'update') {
        listeners.forEach((callback) =>
          callback(message.data)
        );
      }
    });
  }

  subscribe(
    atomKey: string,
    callback: (data: any) => void
  ) {
    if (!this.listeners.has(atomKey)) {
      this.listeners.set(atomKey, new Set());
    }
    this.listeners.get(atomKey)!.add(callback);

    // クリーンアップ関数を返す
    return () => {
      const listeners = this.listeners.get(atomKey);
      if (listeners) {
        listeners.delete(callback);
        if (listeners.size === 0) {
          this.listeners.delete(atomKey);
        }
      }
    };
  }

  broadcast(
    atomKey: string,
    data: any,
    type: SyncMessage['type'] = 'update'
  ) {
    const message: SyncMessage = {
      type,
      atomKey,
      data,
      timestamp: Date.now(),
      tabId: this.tabId,
    };

    this.channel.postMessage(message);
  }

  destroy() {
    this.channel.close();
    this.listeners.clear();
  }
}

const syncManager = new SyncManager();

同期対応 atom の実装です。

typescript// sync-atom.ts
import { atom } from 'jotai';

export const createSyncedAtom = <T>(
  key: string,
  defaultValue: T,
  storage: ReturnType<typeof createLocalForageAtom<T>>
) => {
  const syncedAtom = atom(
    async (get) => {
      return await get(storage);
    },
    async (get, set, update: T) => {
      // ローカル更新
      set(storage, update);

      // 他のタブに同期通知
      syncManager.broadcast(key, update);
    }
  );

  // 他のタブからの更新を受信
  const unsubscribe = syncManager.subscribe(key, (data) => {
    // 外部更新をatomに反映
    // 注意: この処理はReactの外部で行われるため
    // 直接setは使用できない
    storage.write(data);
  });

  // クリーンアップ処理は通常、アプリケーション終了時に実行
  // 実際のアプリケーションではusEffect等で管理

  return syncedAtom;
};

React での使用例とクリーンアップ処理です。

tsx// SyncedComponent.tsx
import React, { useEffect, useRef } from 'react';
import { useAtom } from 'jotai';

// 同期対応の設定atom
const syncedSettingsAtom = createSyncedAtom(
  'settings',
  { theme: 'light', notifications: true },
  createLocalForageAtom('settings', 'userSettings', {
    theme: 'light',
    notifications: true,
  })
);

const SyncedComponent: React.FC = () => {
  const [settings, setSettings] = useAtom(
    syncedSettingsAtom
  );
  const mountedRef = useRef(true);

  useEffect(() => {
    return () => {
      mountedRef.current = false;
    };
  }, []);

  const handleThemeChange = (theme: 'light' | 'dark') => {
    setSettings((prev) => ({ ...prev, theme }));
  };

  return (
    <div className={`app-container ${settings.theme}`}>
      <h2>同期対応設定</h2>
      <button onClick={() => handleThemeChange('light')}>
        ライトテーマ
      </button>
      <button onClick={() => handleThemeChange('dark')}>
        ダークテーマ
      </button>
      <p>現在のテーマ: {settings.theme}</p>
      <p>※ 他のタブでも即座に反映されます</p>
    </div>
  );
};

このリアルタイム同期機能により、複数タブ間での一貫した状態管理が実現され、ユーザー体験が大幅に向上いたします。

まとめ

本記事では、jotai の軽量性を保ちながら、IndexedDB と localForage を活用した大容量データの永続化パターンをご紹介いたしました。従来の atom 単体での制約を克服し、現代の Web アプリケーションが要求する大容量データ管理を効率的に実現する方法を学んでいただけたでしょう。

図で理解できる要点:

  • jotai atom をキャッシュレイヤーとして活用し、IndexedDB で実データを永続化
  • localForage による API の抽象化でコードの簡潔性を維持
  • リアルタイム同期により複数タブ間でのデータ一貫性を確保

重要なのは、メモリ効率とパフォーマンスのバランスを取りながら、開発者体験を損なわない設計を心がけることです。遅延読み込み、バックグラウンド永続化、デバウンス処理などの技術を組み合わせることで、スケーラブルな状態管理アーキテクチャを構築できるのです。

特にエンタープライズレベルのアプリケーション開発では、ユーザーが生成する大容量コンテンツ、オフライン対応、PWA 機能など、複雑な要件に対応する必要があります。本記事で紹介したパターンを参考に、プロジェクトの要件に最適な実装を選択していただければと思います。

今後の Web 開発においても、このような技術の組み合わせによって、より良いユーザー体験を提供できる可能性が広がっていくでしょうね。

関連リンク