T-CREATOR

Jotai の永続化戦略比較:atomWithStorage/Redux 風/カスタム Storage

Jotai の永続化戦略比較:atomWithStorage/Redux 風/カスタム Storage

React アプリケーションで状態管理を行う際、ページをリロードしても状態を保持したいというニーズは非常に一般的です。特に、ユーザーの設定やフォーム入力、認証情報などは永続化が必須となるでしょう。

Jotai は軽量でシンプルな状態管理ライブラリですが、状態の永続化には複数のアプローチが存在します。本記事では、atomWithStorage を使った公式の方法、Redux 風のミドルウェアパターン、そしてカスタム Storage 実装の 3 つの戦略を徹底比較していきます。

それぞれの戦略には明確な特徴があり、プロジェクトの要件に応じて最適な選択が変わってきます。実装の難易度、型安全性、パフォーマンス、拡張性といった観点から、どの戦略を選ぶべきか判断できるようになりましょう。

背景

Jotai による状態管理の特徴

Jotai は Recoil にインスパイアされた、Atomic な状態管理ライブラリです。Redux や Zustand と比較して、より小さな粒度で状態を管理できる点が大きな特徴となっています。

atom という最小単位で状態を定義し、それらを組み合わせることで複雑な状態管理を実現します。Provider を使わずとも動作する点や、TypeScript との相性の良さから、多くの開発者に支持されているライブラリですね。

状態永続化の必要性

Web アプリケーションでは、以下のようなケースで状態の永続化が求められます。

#ユースケース永続化の目的
1ユーザー設定テーマ、言語、レイアウト設定を保存
2フォーム入力未送信データの保護、下書き保存
3認証情報トークン、セッション情報の維持
4ショッピングカート商品選択状態の保持
5UI 状態サイドバーの開閉、タブの選択状態

これらの状態をメモリ上だけで管理すると、ページをリロードした瞬間にすべて失われてしまいます。そのため、localStorage や IndexedDB などのブラウザストレージに保存する必要があるのです。

下図は、Jotai の状態管理における永続化の基本フローを示しています。

mermaidflowchart TB
  user["ユーザー操作"] -->|状態変更| atom["Jotai Atom"]
  atom -->|変更検知| persist["永続化層"]
  persist -->|保存| storage["Storage<br/>(localStorage/IndexedDB等)"]
  storage -->|読み込み| init["アプリ初期化"]
  init -->|初期値設定| atom

図で理解できる要点:

  • 状態変更が永続化層を通じてストレージに保存される
  • アプリ初期化時にストレージから状態を復元
  • 永続化層がストレージとアトムの橋渡しを担当

課題

永続化実装における技術的課題

状態の永続化を実装する際には、いくつかの技術的な課題に直面します。これらを適切に解決しなければ、バグやパフォーマンス問題の原因となってしまうでしょう。

データ同期の課題

複数のタブやウィンドウで同じアプリケーションを開いた場合、状態の同期が問題となります。一方のタブで変更した状態が、他方のタブに即座に反映されなければ、ユーザー体験が損なわれてしまいます。

localStorage は storage イベントを発火しますが、このイベントは変更を行ったタブ自身では発火しません。この非対称性を考慮した実装が必要ですね。

パフォーマンスとシリアライゼーション

状態を保存する際には、JSON へのシリアライゼーションが必要です。しかし、以下のようなデータは標準の JSON.stringify では正しく処理できません。

#データ型問題点対処方法
1Date オブジェクト文字列に変換されるカスタムシリアライザ
2Map / Set空オブジェクトになるArray への変換
3循環参照エラーが発生参照の除去
4Functionundefined になる保存対象から除外
5BigIntエラーが発生文字列変換

また、大きなオブジェクトを頻繁に保存すると、メインスレッドをブロックしてしまう可能性があります。

型安全性の維持

TypeScript を使用している場合、localStorage から取得したデータは string | null 型となります。これを適切な型にキャストし、ランタイムでの型検証を行う必要があるでしょう。

型安全性を失うと、予期しないバグの温床となってしまいます。特に、アプリケーションのバージョンアップに伴って状態の構造が変わった場合、古い形式のデータを読み込んでしまうリスクがあります。

下図は、永続化における主要な課題とその関連性を示しています。

mermaidflowchart TD
  challenge["永続化の課題"]
  challenge --> sync["データ同期"]
  challenge --> perf["パフォーマンス"]
  challenge --> type["型安全性"]

  sync --> multi["複数タブ対応"]
  sync --> event["storage イベント"]

  perf --> serial["シリアライゼーション"]
  perf --> throttle["書き込み頻度制御"]

  type --> validation["ランタイム検証"]
  type --> migration["マイグレーション"]

図で理解できる要点:

  • 3 つの主要課題が存在し、それぞれに具体的な対処が必要
  • データ同期、パフォーマンス、型安全性が相互に影響
  • 包括的な戦略が求められる

エラーハンドリングの複雑性

ストレージ操作は失敗する可能性があります。以下のようなエラーケースを適切に処理する必要があります。

エラーコード: QuotaExceededError

javascript// localStorage の容量超過エラー
DOMException: Failed to execute 'setItem' on 'Storage':
Setting the value of 'userState' exceeded the quota.

発生条件:

  • localStorage の容量制限(通常 5-10MB)を超えた場合
  • プライベートブラウジングモードで制限が厳しい場合

解決方法:

  1. 古いデータを削除して容量を確保
  2. より容量の大きい IndexedDB への移行を検討
  3. データ圧縮の実装
  4. ユーザーへのエラー通知と代替手段の提示

解決策

Jotai における状態永続化には、主に 3 つのアプローチがあります。それぞれの戦略には明確な特徴があり、プロジェクトの要件に応じて選択していきましょう。

戦略 1:atomWithStorage(公式ユーティリティ)

atomWithStorage は Jotai が公式に提供する永続化ユーティリティです。最もシンプルで、多くのケースで推奨される方法となっています。

特徴と利点

この戦略の最大の利点は、実装の簡潔さです。通常の atom とほぼ同じ感覚で使用でき、localStorage や sessionStorage との連携が自動的に行われます。

また、複数タブ間での同期も自動的に処理されるため、開発者が意識する必要がありません。型安全性も保たれ、TypeScript との相性も抜群ですね。

実装の基本

まず、必要なパッケージをインストールします。

bashyarn add jotai

次に、atomWithStorage を使用して永続化された atom を定義します。

typescript// atoms/userSettings.ts
import { atomWithStorage } from 'jotai/utils';

// ユーザー設定の型定義
interface UserSettings {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
  fontSize: number;
}

デフォルト値を含めて atom を作成します。

typescript// デフォルト設定
const defaultSettings: UserSettings = {
  theme: 'light',
  language: 'ja',
  fontSize: 16,
};

// localStorage に保存される atom
export const userSettingsAtom =
  atomWithStorage<UserSettings>(
    'userSettings', // localStorage のキー
    defaultSettings // デフォルト値
  );

コンポーネントでの使用は通常の atom と同じです。

typescript// components/SettingsPanel.tsx
import { useAtom } from 'jotai';
import { userSettingsAtom } from '../atoms/userSettings';

export const SettingsPanel = () => {
  // 通常の atom と同様に使用
  const [settings, setSettings] = useAtom(userSettingsAtom);

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

  return (
    <div>
      <p>現在のテーマ: {settings.theme}</p>
      <button onClick={toggleTheme}>テーマ切替</button>
    </div>
  );
};

戦略 2:Redux 風ミドルウェアパターン

Redux のミドルウェアに似た概念を Jotai に適用し、状態変更を検知して永続化する戦略です。より細かい制御が可能となります。

特徴と利点

この戦略では、どの atom を永続化するか、どのタイミングで保存するかを詳細に制御できます。また、保存前の変換処理や、条件付き保存なども実装可能です。

複雑な状態管理を行う大規模アプリケーションで、永続化ロジックを集中管理したい場合に適しています。

実装方法

まず、永続化を管理するミドルウェアを作成します。

typescript// middleware/persistenceMiddleware.ts
import { atom, WritableAtom } from 'jotai';

// 永続化対象の atom を登録する型
type PersistConfig<T> = {
  atom: WritableAtom<T, any, any>;
  key: string;
  storage?: Storage;
};

永続化マネージャーを実装します。

typescript// 永続化マネージャークラス
class PersistenceManager {
  private configs = new Map<
    WritableAtom<any, any, any>,
    string
  >();

  // atom を永続化対象として登録
  register<T>(config: PersistConfig<T>) {
    this.configs.set(config.atom, config.key);
  }

  // 状態を保存
  save<T>(
    atomInstance: WritableAtom<T, any, any>,
    value: T
  ) {
    const key = this.configs.get(atomInstance);
    if (!key) return;

    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('Failed to persist state:', error);
    }
  }
}

export const persistenceManager = new PersistenceManager();

永続化ラッパー関数を作成します。

typescript// utils/persistedAtom.ts
import { atom, WritableAtom } from 'jotai';
import { persistenceManager } from '../middleware/persistenceMiddleware';

// 永続化された atom を作成するヘルパー
export function createPersistedAtom<T>(
  key: string,
  initialValue: T
): WritableAtom<T, [T], void> {
  // localStorage から初期値を読み込み
  const storedValue = localStorage.getItem(key);
  const value = storedValue
    ? JSON.parse(storedValue)
    : initialValue;

  // 通常の atom を作成
  const baseAtom = atom(value);

  // 書き込み可能な atom でラップ
  const persistedAtom = atom(
    (get) => get(baseAtom),
    (get, set, update: T) => {
      set(baseAtom, update);
      persistenceManager.save(baseAtom, update);
    }
  );

  return persistedAtom;
}

使用例です。

typescript// atoms/cart.ts
import { createPersistedAtom } from '../utils/persistedAtom';

interface CartItem {
  id: string;
  name: string;
  quantity: number;
}

// ショッピングカートの状態
export const cartAtom = createPersistedAtom<CartItem[]>(
  'shoppingCart',
  []
);

戦略 3:カスタム Storage 実装

独自のストレージ層を実装し、localStorage 以外のストレージ(IndexedDB、Cookie、外部 API など)に対応する戦略です。

特徴と利点

この戦略の最大の利点は、柔軟性です。ストレージの種類を自由に選択でき、暗号化や圧縮などの前処理・後処理を追加できます。

また、サーバーサイドとの同期や、複数ストレージへの同時保存なども実装可能となっています。

カスタム Storage インターフェース

まず、Storage の抽象インターフェースを定義します。

typescript// storage/StorageInterface.ts

// ストレージの共通インターフェース
export interface CustomStorage {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, value: string): Promise<void>;
  removeItem(key: string): Promise<void>;
}

IndexedDB を使用した実装例です。

typescript// storage/IndexedDBStorage.ts
export class IndexedDBStorage implements CustomStorage {
  private dbName: string
  private storeName: string

  constructor(dbName = 'AppDB', storeName = 'AppStore') {
    this.dbName = dbName
    this.storeName = storeName
  }

  // データベース接続を取得
  private async getDB(): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1)

      request.onerror = () => reject(request.error)
      request.onsuccess = () => resolve(request.result)

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName)
        }
      }
    })
  }

データの取得処理を実装します。

typescript  // データ取得
  async getItem(key: string): Promise<string | null> {
    try {
      const db = await this.getDB()
      return new Promise((resolve, reject) => {
        const transaction = db.transaction([this.storeName], 'readonly')
        const store = transaction.objectStore(this.storeName)
        const request = store.get(key)

        request.onsuccess = () => {
          resolve(request.result || null)
        }
        request.onerror = () => reject(request.error)
      })
    } catch (error) {
      console.error('IndexedDB getItem error:', error)
      return null
    }
  }

データの保存処理を実装します。

typescript  // データ保存
  async setItem(key: string, value: string): Promise<void> {
    try {
      const db = await this.getDB()
      return new Promise((resolve, reject) => {
        const transaction = db.transaction([this.storeName], 'readwrite')
        const store = transaction.objectStore(this.storeName)
        const request = store.put(value, key)

        request.onsuccess = () => resolve()
        request.onerror = () => reject(request.error)
      })
    } catch (error) {
      console.error('IndexedDB setItem error:', error)
      throw error
    }
  }

  // データ削除
  async removeItem(key: string): Promise<void> {
    const db = await this.getDB()
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readwrite')
      const store = transaction.objectStore(this.storeName)
      const request = store.delete(key)

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

カスタムストレージを使用した atom を作成します。

typescript// atoms/customStorageAtom.ts
import { atom } from 'jotai';
import { IndexedDBStorage } from '../storage/IndexedDBStorage';

const storage = new IndexedDBStorage();

// カスタムストレージを使用した atom 作成関数
export function atomWithCustomStorage<T>(
  key: string,
  initialValue: T
) {
  // 初期値読み込み用の非同期 atom
  const baseAtom = atom<T>(initialValue);

  // 読み込み時に IndexedDB から取得
  const derivedAtom = atom(
    async (get) => {
      const stored = await storage.getItem(key);
      if (stored) {
        return JSON.parse(stored) as T;
      }
      return get(baseAtom);
    },
    async (get, set, update: T) => {
      set(baseAtom, update);
      await storage.setItem(key, JSON.stringify(update));
    }
  );

  return derivedAtom;
}

3 つの戦略の比較

下表は、各戦略の特徴を比較したものです。プロジェクトの要件に応じて選択しましょう。

#観点atomWithStorageRedux 風カスタム Storage
1実装難易度★ 簡単★★ 中程度★★★ 難しい
2型安全性★★★ 高い★★ 中程度★★★ 高い(実装次第)
3柔軟性★ 低い★★ 中程度★★★ 非常に高い
4パフォーマンス★★ 良好★★ 良好★★★ 最適化可能
5タブ同期★★★ 自動★ 手動実装★ 手動実装
6ストレージ種類localStorage のみlocalStorage のみ自由に選択可能
7エラーハンドリング★★ 基本的★★★ 詳細制御可能★★★ 完全制御可能

具体例

実際のアプリケーションで各戦略を使用する具体的な例を見ていきましょう。ユーザー設定管理、ショッピングカート、そして大規模データ管理の 3 つのユースケースを紹介します。

ユースケース 1:ユーザー設定管理(atomWithStorage)

シンプルなユーザー設定の管理には、atomWithStorage が最適です。完全な実装例を示します。

typescript// atoms/theme.ts
import { atomWithStorage } from 'jotai/utils';

// テーマ設定の型
export type Theme = 'light' | 'dark' | 'auto';

export interface ThemeSettings {
  mode: Theme;
  primaryColor: string;
  fontSize: 'small' | 'medium' | 'large';
}

// デフォルト設定
const defaultTheme: ThemeSettings = {
  mode: 'auto',
  primaryColor: '#3b82f6',
  fontSize: 'medium',
};

// 永続化された theme atom
export const themeAtom = atomWithStorage<ThemeSettings>(
  'app-theme',
  defaultTheme
);

テーマ設定を使用するコンポーネントを作成します。

typescript// components/ThemeSelector.tsx
import { useAtom } from 'jotai';
import { themeAtom } from '../atoms/theme';

export const ThemeSelector = () => {
  const [theme, setTheme] = useAtom(themeAtom);

  // テーマモード変更
  const handleModeChange = (mode: Theme) => {
    setTheme((prev) => ({ ...prev, mode }));
  };

  // フォントサイズ変更
  const handleFontSizeChange = (
    fontSize: 'small' | 'medium' | 'large'
  ) => {
    setTheme((prev) => ({ ...prev, fontSize }));
  };

  return (
    <div>
      <h3>テーマ設定</h3>
      <div>
        <label>モード:</label>
        <select
          value={theme.mode}
          onChange={(e) =>
            handleModeChange(e.target.value as Theme)
          }
        >
          <option value='light'>ライト</option>
          <option value='dark'>ダーク</option>
          <option value='auto'>自動</option>
        </select>
      </div>
      <div>
        <label>フォントサイズ:</label>
        <select
          value={theme.fontSize}
          onChange={(e) =>
            handleFontSizeChange(
              e.target.value as 'small' | 'medium' | 'large'
            )
          }
        >
          <option value='small'></option>
          <option value='medium'></option>
          <option value='large'></option>
        </select>
      </div>
    </div>
  );
};

下図は、atomWithStorage による設定管理のフローを示しています。

mermaidsequenceDiagram
  participant U as ユーザー
  participant C as ThemeSelector
  participant A as themeAtom
  participant L as localStorage

  U->>C: テーマ変更
  C->>A: setTheme()
  A->>L: 自動保存
  A-->>C: 状態更新
  C-->>U: UI 反映

  Note over U,L: ページリロード

  C->>A: useAtom()
  A->>L: 値取得
  L-->>A: 保存済み設定
  A-->>C: 初期値設定
  C-->>U: UI 復元

図で理解できる要点:

  • 状態変更が自動的に localStorage に保存される
  • リロード時に保存された設定が自動復元される
  • 開発者はストレージ操作を意識する必要がない

ユースケース 2:ショッピングカート(Redux 風)

複雑なビジネスロジックを含むショッピングカートには、Redux 風のミドルウェアパターンが適しています。

typescript// atoms/cart.ts
import { atom } from 'jotai';

// 商品の型定義
export interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
}

// カートアイテムの型定義
export interface CartItem extends Product {
  quantity: number;
  addedAt: string;
}

カート操作のアクション定義とリデューサーを作成します。

typescript// カートの状態管理 atom
const cartBaseAtom = atom<CartItem[]>([])

// localStorage から初期値を読み込み
if (typeof window !== 'undefined') {
  const stored = localStorage.getItem('shopping-cart')
  if (stored) {
    try {
      const parsed = JSON.parse(stored)
      cartBaseAtom.init = parsed
    } catch (error) {
      console.error('Failed to parse cart data:', error)
    }
  }
}

// カート操作用の派生 atom
export const cartAtom = atom(
  (get) => get(cartBaseAtom),
  (get, set, action: CartAction) => {
    const currentCart = get(cartBaseAtom)
    let newCart: CartItem[]

    switch (action.type) {
      case 'ADD_ITEM':
        // 既存アイテムの確認
        const existingItem = currentCart.find(
          (item) => item.id === action.payload.id
        )

        if (existingItem) {
          // 数量を増やす
          newCart = currentCart.map((item) =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        } else {
          // 新規追加
          newCart = [
            ...currentCart,
            {
              ...action.payload,
              quantity: 1,
              addedAt: new Date().toISOString(),
            },
          ]
        }
        break

その他のアクションを実装します。

typescript      case 'REMOVE_ITEM':
        // アイテムを削除
        newCart = currentCart.filter(
          (item) => item.id !== action.payload
        )
        break

      case 'UPDATE_QUANTITY':
        // 数量を更新
        newCart = currentCart.map((item) =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        )
        break

      case 'CLEAR_CART':
        // カートをクリア
        newCart = []
        break

      default:
        newCart = currentCart
    }

    // 状態を更新
    set(cartBaseAtom, newCart)

    // localStorage に保存
    try {
      localStorage.setItem('shopping-cart', JSON.stringify(newCart))
    } catch (error) {
      if (error instanceof DOMException && error.name === 'QuotaExceededError') {
        // 容量超過エラーの処理
        console.error('Storage quota exceeded. Clearing old items...')
        // 古いアイテムを削除して再試行
        const recentItems = newCart.slice(-10)
        localStorage.setItem('shopping-cart', JSON.stringify(recentItems))
        set(cartBaseAtom, recentItems)
      }
    }
  }
)

// アクションの型定義
type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'CLEAR_CART' }

カートコンポーネントを実装します。

typescript// components/ShoppingCart.tsx
import { useAtom } from 'jotai';
import { cartAtom } from '../atoms/cart';

export const ShoppingCart = () => {
  const [cart, dispatch] = useAtom(cartAtom);

  // 合計金額を計算
  const totalPrice = cart.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  // アイテムを削除
  const removeItem = (id: string) => {
    dispatch({ type: 'REMOVE_ITEM', payload: id });
  };

  // 数量を変更
  const updateQuantity = (id: string, quantity: number) => {
    if (quantity <= 0) {
      removeItem(id);
    } else {
      dispatch({
        type: 'UPDATE_QUANTITY',
        payload: { id, quantity },
      });
    }
  };

  return (
    <div>
      <h2>ショッピングカート ({cart.length} 点)</h2>
      {cart.map((item) => (
        <div key={item.id}>
          <h4>{item.name}</h4>
          <p>価格: ¥{item.price.toLocaleString()}</p>
          <input
            type='number'
            value={item.quantity}
            onChange={(e) =>
              updateQuantity(
                item.id,
                parseInt(e.target.value)
              )
            }
            min='0'
          />
          <button onClick={() => removeItem(item.id)}>
            削除
          </button>
        </div>
      ))}
      <h3>合計: ¥{totalPrice.toLocaleString()}</h3>
    </div>
  );
};

ユースケース 3:大規模データ管理(カスタム Storage)

大量のデータや、より高度な機能が必要な場合は、IndexedDB を使用したカスタムストレージが効果的です。

typescript// storage/CompressedStorage.ts
import { IndexedDBStorage } from './IndexedDBStorage';
import pako from 'pako';

// 圧縮機能付きストレージ
export class CompressedStorage extends IndexedDBStorage {
  // データを圧縮して保存
  async setItem(key: string, value: string): Promise<void> {
    try {
      // データを圧縮
      const compressed = pako.deflate(value, {
        to: 'string',
      });
      await super.setItem(key, compressed);
    } catch (error) {
      console.error('Compression failed:', error);
      // 圧縮失敗時は通常保存
      await super.setItem(key, value);
    }
  }

  // データを解凍して取得
  async getItem(key: string): Promise<string | null> {
    try {
      const compressed = await super.getItem(key);
      if (!compressed) return null;

      // データを解凍
      const decompressed = pako.inflate(compressed, {
        to: 'string',
      });
      return decompressed;
    } catch (error) {
      console.error('Decompression failed:', error);
      return null;
    }
  }
}

大規模データ用の atom を作成します。

typescript// atoms/largeDataAtom.ts
import { atom } from 'jotai';
import { CompressedStorage } from '../storage/CompressedStorage';

const storage = new CompressedStorage();

// 大規模データの型定義
export interface DataRecord {
  id: string;
  timestamp: number;
  data: any;
}

// カスタムストレージを使用した atom
export const largeDataAtom = atom(
  async (get) => {
    const stored = await storage.getItem('large-data');
    if (stored) {
      return JSON.parse(stored) as DataRecord[];
    }
    return [];
  },
  async (get, set, update: DataRecord[]) => {
    // データ保存前に古いレコードを削除(最新 1000 件のみ保持)
    const trimmed = update.slice(-1000);
    await storage.setItem(
      'large-data',
      JSON.stringify(trimmed)
    );
  }
);

下図は、カスタムストレージによるデータフローを示しています。

mermaidflowchart LR
  app["アプリケーション"] -->|データ書き込み| atom["largeDataAtom"]
  atom -->|圧縮| compress["pako.deflate"]
  compress -->|保存| idb[("IndexedDB")]

  idb -->|読み込み| decompress["pako.inflate"]
  decompress -->|解凍| atom
  atom -->|データ取得| app

図で理解できる要点:

  • データが自動的に圧縮されてから保存される
  • IndexedDB により大容量データを扱える
  • 読み込み時に自動解凍される

まとめ

Jotai の状態永続化には、atomWithStorage、Redux 風ミドルウェア、カスタム Storage の 3 つの主要な戦略があります。それぞれに明確な特徴があり、適切なユースケースが存在しますね。

戦略選択のガイドライン

atomWithStorage を選ぶべきケース

  • シンプルなユーザー設定や UI 状態の保存
  • 複数タブでの自動同期が必要
  • 実装コストを最小限に抑えたい
  • localStorage で十分な小規模データ

Redux 風ミドルウェアを選ぶべきケース

  • 複雑なビジネスロジックを含む状態管理
  • 保存前後の処理をカスタマイズしたい
  • アクションベースの状態管理に慣れている
  • 永続化ロジックを集中管理したい

カスタム Storage を選ぶべきケース

  • 大容量データの保存(IndexedDB 使用)
  • 暗号化や圧縮が必要
  • 複数のストレージを組み合わせたい
  • サーバーサイドとの同期が必要

実装時の注意点

どの戦略を選択する場合でも、以下の点に注意しましょう。

エラーハンドリングは必須です。ストレージ操作は失敗する可能性があるため、適切な try-catch と代替処理を実装してください。

型安全性を維持するために、TypeScript の型定義を活用し、ランタイムでの検証も行いましょう。

パフォーマンスを考慮し、頻繁な書き込みが発生する場合は debounce や throttle を実装することをおすすめします。

マイグレーション戦略も重要です。アプリケーションのバージョンアップ時に、古い形式のデータを適切に変換する仕組みを用意しておきましょう。

本記事で紹介した 3 つの戦略を理解し、プロジェクトの要件に応じて最適な方法を選択することで、堅牢で保守性の高い状態管理を実現できます。まずは atomWithStorage から始めて、必要に応じてより高度な戦略に移行していくのが良いアプローチですね。

関連リンク