T-CREATOR

Zustand のシリアライズとデシリアライズ:状態の保存・復元の仕組み解説

Zustand のシリアライズとデシリアライズ:状態の保存・復元の仕組み解説

Web アプリケーションにおいて、ユーザーの操作状態や設定情報を永続化することは、優れたユーザー体験を提供するために不可欠な機能です。特にブラウザリロードやページ遷移後も状態を維持することで、ユーザーは中断した作業を継続でき、設定を再入力する手間を省くことができます。

本記事では、軽量で直感的な状態管理ライブラリである Zustand を使用して、状態のシリアライズ(直列化)とデシリアライズ(逆直列化)を効率的に実装する方法について、基本的な概念から実践的なテクニックまで段階的に解説していきます。

背景

Web アプリケーションにおける状態永続化の必要性

現代の Web アプリケーションでは、リッチなユーザーインターフェースと複雑な状態管理が求められています。ユーザーが長時間にわたって作業を行う場合、以下のような状況で状態の永続化が重要になります。

  • 作業の継続性:フォーム入力途中でのブラウザクラッシュからの復旧
  • ユーザー設定の保持:テーマ、言語設定、レイアウト情報の維持
  • ショッピング体験:カート内容の保持とセッション復旧
  • プログレッシブ Web アプリ(PWA):オフライン状態での操作とデータ同期

これらの要件を満たすためには、JavaScript オブジェクトで管理されている状態を、ブラウザのストレージ機能を使って保存・復元する仕組みが必要です。

ブラウザリロード時の状態消失問題

従来の React アプリケーションでは、状態はメモリ上でのみ管理されるため、以下のような問題が発生します:

javascript// 一般的なReact状態管理の例
const [userSettings, setUserSettings] = useState({
  theme: 'dark',
  language: 'ja',
  sidebarCollapsed: true,
});

// ブラウザリロード時:この状態は完全に失われる
// ユーザーは再度設定を行う必要がある

この問題により、ユーザーは以下のような不便を経験することになります:

  • 設定した内容が消失し、再設定が必要
  • 入力中のフォームデータが失われる
  • ログイン状態の維持ができない
  • 作業中のデータが消失する

Zustand の基本的な状態管理とデータ永続化の課題

Zustand は軽量で使いやすい状態管理ライブラリですが、基本的な実装では状態の永続化機能は提供されていません:

javascriptimport { create } from 'zustand';

// 基本的なZustandストア(永続化なし)
const useStore = create((set) => ({
  user: null,
  theme: 'light',
  settings: {},
  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
  updateSettings: (settings) =>
    set((state) => ({
      settings: { ...state.settings, ...settings },
    })),
}));

// この状態はページリロード時に初期値にリセットされる

このような基本実装では、ブラウザリロード時に状態が失われてしまうため、ユーザー体験の向上を図るには追加の実装が必要となります。

課題

複雑なデータ構造のシリアライズ困難

JavaScript の標準的なシリアライズ機能である JSON.stringify は、多くの制限があります。特に以下のようなデータ構造を含む Zustand ストアでは問題が発生します:

javascript// 問題のあるデータ構造の例
const problematicStore = create((set) => ({
  // Date オブジェクトは文字列に変換される
  createdAt: new Date(),

  // Set や Map は空のオブジェクトになる
  tags: new Set(['react', 'zustand']),
  userMap: new Map([['user1', { name: 'Alice' }]]),

  // 関数は無視される
  calculateTotal: () => {
    /* 計算ロジック */
  },

  // undefined は無視される
  optionalValue: undefined,

  // 循環参照はエラーになる
  selfRef: null, // 後で自分自身を参照
}));

// シリアライズ時のエラー例
try {
  const serialized = JSON.stringify(
    problematicStore.getState()
  );
} catch (error) {
  // TypeError: Converting circular structure to JSON
  console.error(error);
}

デシリアライズ時のデータ整合性問題

保存されたデータを復元する際に、以下のような整合性の問題が発生することがあります:

javascript// 保存時の状態
const originalState = {
  version: '1.0.0',
  user: { id: 1, name: 'Alice', lastLogin: new Date() },
  settings: { theme: 'dark' },
};

// localStorage から復元した状態
const restoredState = JSON.parse(
  localStorage.getItem('app-state')
);
// {
//   version: '1.0.0',
//   user: { id: 1, name: 'Alice', lastLogin: '2024-01-15T10:30:00.000Z' },
//   settings: { theme: 'dark' }
// }

// 問題:lastLogin が Date オブジェクトではなく文字列になっている
if (restoredState.user.lastLogin instanceof Date) {
  // このチェックは false になる
  console.log('Date object detected');
} else {
  console.log(
    'String detected:',
    typeof restoredState.user.lastLogin
  );
  // 'String detected: string'
}

さらに、アプリケーションのバージョンアップ時に以下のような問題も発生します:

javascript// アプリケーション更新前の状態構造
const oldState = {
  user: { name: 'Alice', age: 25 },
};

// アプリケーション更新後の期待する状態構造
const newStateStructure = {
  user: {
    profile: { name: 'Alice', age: 25 },
    preferences: { theme: 'light' }, // 新しいフィールド
  },
};

// 古い形式のデータを読み込むとエラーが発生
// TypeError: Cannot read property 'theme' of undefined

パフォーマンスとストレージ容量の制約

ブラウザのストレージには容量制限があり、大きな状態を持つアプリケーションでは以下のような問題が発生します:

javascript// 大容量データの例
const largeDataStore = create((set) => ({
  // 大量のユーザーデータ
  users: Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `User ${i}`,
    profile: {
      bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...'.repeat(
        10
      ),
      avatar:
        'data:image/png;base64,' +
        'iVBORw0KGgoAAAANSUhEUgAA...'.repeat(100),
    },
  })),

  // 複雑な設定オブジェクト
  complexSettings: {
    ui: {
      /* 数百のUI設定項目 */
    },
    features: {
      /* 機能フラグの配列 */
    },
    cache: {
      /* キャッシュデータ */
    },
  },
}));

// localStorage 容量制限エラー
try {
  const serialized = JSON.stringify(
    largeDataStore.getState()
  );
  localStorage.setItem('app-state', serialized);
} catch (error) {
  // DOMException: Failed to execute 'setItem' on 'Storage':
  // Setting the value of 'app-state' exceeded the quota.
  console.error('Storage quota exceeded:', error);
}

また、シリアライズ・デシリアライズ処理自体のパフォーマンスも問題となります:

javascript// パフォーマンス測定の例
const measureSerializationPerformance = () => {
  const largeState = {
    /* 大量データ */
  };

  console.time('Serialization');
  const serialized = JSON.stringify(largeState);
  console.timeEnd('Serialization');
  // Serialization: 156.789ms

  console.time('Deserialization');
  const deserialized = JSON.parse(serialized);
  console.timeEnd('Deserialization');
  // Deserialization: 89.234ms

  // UI がブロックされる可能性がある
};

これらの課題を解決するために、次のセクションでは効果的な解決策を段階的に紹介していきます。

解決策

localStorage 連携による基本的な永続化

最もシンプルな永続化手法として、localStorage と Zustand の組み合わせがあります。以下は基本的な実装例です:

javascriptimport { create } from 'zustand';

// 基本的な永続化ヘルパー関数
const createPersistentStore = (name, initialState) => {
  // localStorage からデータを読み込み
  const loadState = () => {
    try {
      const serializedState = localStorage.getItem(name);
      if (serializedState === null) {
        return initialState;
      }
      return JSON.parse(serializedState);
    } catch (error) {
      console.warn(
        `Failed to load state from localStorage:`,
        error
      );
      return initialState;
    }
  };

  // localStorage にデータを保存
  const saveState = (state) => {
    try {
      const serializedState = JSON.stringify(state);
      localStorage.setItem(name, serializedState);
    } catch (error) {
      console.error(
        `Failed to save state to localStorage:`,
        error
      );
    }
  };

  return create((set, get) => ({
    ...loadState(),

    // 状態更新と同時に保存を行うヘルパー
    setPersistent: (updater) => {
      set((state) => {
        const newState =
          typeof updater === 'function'
            ? updater(state)
            : updater;
        const finalState = { ...state, ...newState };
        saveState(finalState);
        return newState;
      });
    },

    // 手動保存機能
    save: () => {
      saveState(get());
    },

    // データクリア機能
    clear: () => {
      localStorage.removeItem(name);
      set(initialState);
    },
  }));
};

// 使用例
const useUserPreferencesStore = createPersistentStore(
  'user-preferences',
  {
    theme: 'light',
    language: 'ja',
    fontSize: 16,
    sidebarOpen: true,
    notifications: {
      email: true,
      browser: false,
      mobile: true,
    },
  }
);

// 使用例コンポーネント
const SettingsPanel = () => {
  const {
    theme,
    language,
    fontSize,
    notifications,
    setState,
  } = useUserPreferencesStore();

  const handleThemeChange = (newTheme) => {
    setState({ theme: newTheme });
    // 状態変更と同時に localStorage に保存される
  };

  const handleNotificationChange = (type, enabled) => {
    setState((state) => ({
      notifications: {
        ...state.notifications,
        [type]: enabled,
      },
    }));
  };

  return (
    <div className='settings-panel'>
      <h2>設定</h2>

      <div className='setting-group'>
        <label>テーマ</label>
        <select
          value={theme}
          onChange={(e) =>
            handleThemeChange(e.target.value)
          }
        >
          <option value='light'>ライト</option>
          <option value='dark'>ダーク</option>
        </select>
      </div>

      <div className='setting-group'>
        <label>言語</label>
        <select
          value={language}
          onChange={(e) =>
            setState({ language: e.target.value })
          }
        >
          <option value='ja'>日本語</option>
          <option value='en'>English</option>
        </select>
      </div>

      <div className='setting-group'>
        <label>フォントサイズ</label>
        <input
          type='range'
          min='12'
          max='24'
          value={fontSize}
          onChange={(e) =>
            setState({ fontSize: Number(e.target.value) })
          }
        />
        <span>{fontSize}px</span>
      </div>

      <div className='setting-group'>
        <h3>通知設定</h3>
        <label>
          <input
            type='checkbox'
            checked={notifications.email}
            onChange={(e) =>
              handleNotificationChange(
                'email',
                e.target.checked
              )
            }
          />
          メール通知
        </label>
        <label>
          <input
            type='checkbox'
            checked={notifications.browser}
            onChange={(e) =>
              handleNotificationChange(
                'browser',
                e.target.checked
              )
            }
          />
          ブラウザ通知
        </label>
      </div>
    </div>
  );
};

sessionStorage 活用パターン

一時的な状態の保持には sessionStorage が適しています。タブが閉じられるまでの間だけデータを保持したい場合に有効です:

javascript// sessionStorage を使用した一時的な状態管理
const createSessionStore = (name, initialState) => {
  const loadSessionState = () => {
    try {
      const serializedState = sessionStorage.getItem(name);
      if (serializedState === null) {
        return initialState;
      }
      return JSON.parse(serializedState);
    } catch (error) {
      console.warn(`Failed to load session state:`, error);
      return initialState;
    }
  };

  const saveSessionState = (state) => {
    try {
      const serializedState = JSON.stringify(state);
      sessionStorage.setItem(name, serializedState);
    } catch (error) {
      console.error(`Failed to save session state:`, error);
    }
  };

  return create((set, get) => ({
    ...loadSessionState(),

    setSession: (updater) => {
      set((state) => {
        const newState =
          typeof updater === 'function'
            ? updater(state)
            : updater;
        const finalState = { ...state, ...newState };
        saveSessionState(finalState);
        return newState;
      });
    },

    clearSession: () => {
      sessionStorage.removeItem(name);
      set(initialState);
    },
  }));
};

// フォーム一時保存の例
const useFormStore = createSessionStore('form-draft', {
  formData: {},
  currentStep: 1,
  isDirty: false,
});

const MultiStepForm = () => {
  const { formData, currentStep, setSession } =
    useFormStore();

  const updateFormData = (field, value) => {
    setSession((state) => ({
      formData: { ...state.formData, [field]: value },
      isDirty: true,
    }));
  };

  // フォーム入力中の状態がセッション間で保持される
  return (
    <form>
      <input
        value={formData.username || ''}
        onChange={(e) =>
          updateFormData('username', e.target.value)
        }
        placeholder='ユーザー名'
      />
    </form>
  );
};

カスタムシリアライザーの実装

複雑なデータ構造を適切に処理するために、カスタムシリアライザーを実装できます:

javascript// 高度なシリアライザークラス
class AdvancedSerializer {
  constructor() {
    this.revivers = new Map();
    this.replacers = new Map();

    // 標準的な型のハンドラーを登録
    this.registerType(
      'Date',
      (value) => ({
        __type: 'Date',
        value: value.toISOString(),
      }),
      (data) => new Date(data.value)
    );

    this.registerType(
      'Set',
      (value) => ({
        __type: 'Set',
        value: Array.from(value),
      }),
      (data) => new Set(data.value)
    );

    this.registerType(
      'Map',
      (value) => ({
        __type: 'Map',
        value: Array.from(value.entries()),
      }),
      (data) => new Map(data.value)
    );

    this.registerType(
      'RegExp',
      (value) => ({
        __type: 'RegExp',
        source: value.source,
        flags: value.flags,
      }),
      (data) => new RegExp(data.source, data.flags)
    );
  }

  registerType(typeName, replacer, reviver) {
    this.replacers.set(typeName, replacer);
    this.revivers.set(typeName, reviver);
  }

  serialize(obj) {
    const replacer = (key, value) => {
      if (value instanceof Date) {
        return this.replacers.get('Date')(value);
      }
      if (value instanceof Set) {
        return this.replacers.get('Set')(value);
      }
      if (value instanceof Map) {
        return this.replacers.get('Map')(value);
      }
      if (value instanceof RegExp) {
        return this.replacers.get('RegExp')(value);
      }

      // 循環参照のチェック
      if (typeof value === 'object' && value !== null) {
        if (this.seen && this.seen.has(value)) {
          return {
            __type: 'CircularReference',
            path: this.getPath(value),
          };
        }
      }

      return value;
    };

    try {
      this.seen = new WeakSet();
      return JSON.stringify(obj, replacer);
    } catch (error) {
      throw new Error(
        `Serialization failed: ${error.message}`
      );
    } finally {
      this.seen = null;
    }
  }

  deserialize(jsonString) {
    const reviver = (key, value) => {
      if (
        typeof value === 'object' &&
        value !== null &&
        value.__type
      ) {
        const reviver = this.revivers.get(value.__type);
        if (reviver) {
          return reviver(value);
        }
      }
      return value;
    };

    try {
      return JSON.parse(jsonString, reviver);
    } catch (error) {
      throw new Error(
        `Deserialization failed: ${error.message}`
      );
    }
  }
}

// カスタムシリアライザーを使用したストア
const serializer = new AdvancedSerializer();

const createAdvancedPersistentStore = (
  name,
  initialState
) => {
  const loadState = () => {
    try {
      const serializedState = localStorage.getItem(name);
      if (serializedState === null) {
        return initialState;
      }
      return serializer.deserialize(serializedState);
    } catch (error) {
      console.warn(`Failed to deserialize state:`, error);
      // 破損したデータの場合は初期状態を返す
      localStorage.removeItem(name);
      return initialState;
    }
  };

  const saveState = (state) => {
    try {
      const serializedState = serializer.serialize(state);
      localStorage.setItem(name, serializedState);
    } catch (error) {
      console.error(`Failed to serialize state:`, error);
    }
  };

  return create((set, get) => ({
    ...loadState(),

    updateAndSave: (updater) => {
      set((state) => {
        const newState =
          typeof updater === 'function'
            ? updater(state)
            : updater;
        const finalState = { ...state, ...newState };
        saveState(finalState);
        return newState;
      });
    },
  }));
};

// 複雑なデータ構造を含むストアの例
const useComplexStore = createAdvancedPersistentStore(
  'complex-store',
  {
    createdAt: new Date(),
    tags: new Set(['javascript', 'react']),
    userPreferences: new Map([
      ['theme', 'dark'],
      ['language', 'ja'],
    ]),
    validationRules: /^[a-zA-Z0-9]+$/,
    metadata: {
      version: '1.0.0',
      lastModified: new Date(),
    },
  }
);

TypeScript での型安全な保存・復元

TypeScript を使用することで、シリアライズ・デシリアライズ処理においても型安全性を確保できます:

typescript// 型安全なシリアライゼーション
interface SerializableState {
  [key: string]: any;
}

interface PersistentStore<T extends SerializableState> {
  getState: () => T;
  setState: (state: Partial<T>) => void;
  persist: () => Promise<void>;
  restore: () => Promise<void>;
  clear: () => void;
}

// 型安全なストア作成関数
function createTypedPersistentStore<
  T extends SerializableState
>(
  name: string,
  initialState: T,
  validator?: (state: any) => state is T
): PersistentStore<T> {
  const validateState = (state: any): state is T => {
    if (validator) {
      return validator(state);
    }

    // 基本的な型チェック
    if (typeof state !== 'object' || state === null) {
      return false;
    }

    // 必要なプロパティの存在確認
    for (const key in initialState) {
      if (!(key in state)) {
        return false;
      }
    }

    return true;
  };

  const loadState = async (): Promise<T> => {
    try {
      const serializedState = localStorage.getItem(name);
      if (serializedState === null) {
        return initialState;
      }

      const parsedState = JSON.parse(serializedState);

      if (validateState(parsedState)) {
        return parsedState;
      } else {
        console.warn(
          'Invalid state structure detected, using initial state'
        );
        return initialState;
      }
    } catch (error) {
      console.error('Failed to load state:', error);
      return initialState;
    }
  };

  const saveState = async (state: T): Promise<void> => {
    try {
      const serializedState = JSON.stringify(state);
      localStorage.setItem(name, serializedState);
    } catch (error) {
      throw new Error(
        `Failed to save state: ${error.message}`
      );
    }
  };

  let currentState = initialState;

  return {
    getState: () => currentState,

    setState: (newState: Partial<T>) => {
      currentState = { ...currentState, ...newState };
    },

    persist: async () => {
      await saveState(currentState);
    },

    restore: async () => {
      currentState = await loadState();
    },

    clear: () => {
      localStorage.removeItem(name);
      currentState = initialState;
    },
  };
}

// 使用例:ユーザー設定の型安全な管理
interface UserSettings {
  theme: 'light' | 'dark';
  language: 'en' | 'ja' | 'es';
  notifications: {
    email: boolean;
    push: boolean;
  };
  layout: {
    sidebarCollapsed: boolean;
    gridSize: number;
  };
}

// バリデーション関数
const isUserSettings = (obj: any): obj is UserSettings => {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    ['light', 'dark'].includes(obj.theme) &&
    ['en', 'ja', 'es'].includes(obj.language) &&
    typeof obj.notifications === 'object' &&
    typeof obj.notifications.email === 'boolean' &&
    typeof obj.notifications.push === 'boolean' &&
    typeof obj.layout === 'object' &&
    typeof obj.layout.sidebarCollapsed === 'boolean' &&
    typeof obj.layout.gridSize === 'number'
  );
};

const settingsStore =
  createTypedPersistentStore<UserSettings>(
    'user-settings',
    {
      theme: 'light',
      language: 'en',
      notifications: {
        email: true,
        push: false,
      },
      layout: {
        sidebarCollapsed: false,
        gridSize: 12,
      },
    },
    isUserSettings
  );

// Zustand との統合
const useUserSettingsStore = create<
  UserSettings & {
    updateTheme: (theme: UserSettings['theme']) => void;
    updateLanguage: (
      language: UserSettings['language']
    ) => void;
    save: () => Promise<void>;
    load: () => Promise<void>;
  }
>((set, get) => ({
  ...settingsStore.getState(),

  updateTheme: (theme) => {
    set({ theme });
    settingsStore.setState({ theme });
  },

  updateLanguage: (language) => {
    set({ language });
    settingsStore.setState({ language });
  },

  save: async () => {
    settingsStore.setState(get());
    await settingsStore.persist();
  },

  load: async () => {
    await settingsStore.restore();
    set(settingsStore.getState());
  },
}));

まとめ

Zustand での状態のシリアライズとデシリアライズを効率的に実装するためには、以下のポイントが重要です。

段階的なアプローチの重要性

基本的な JSON ベースの永続化から始めて、アプリケーションの要件に応じて徐々に機能を拡張していくことが成功の鍵となります。無闇に複雑なシステムを構築するのではなく、必要に応じて以下の順序で機能を追加することをお勧めします。

  1. 基本的な localStorage 連携:シンプルなデータ構造での永続化
  2. エラーハンドリングの強化:データ破損や容量制限への対応
  3. 特殊型対応:Date、Set、Map などの高度な型への対応
  4. バックアップとリカバリー:データ損失防止のための冗長化

データ整合性の確保

永続化システムにおいて最も重要なのは、データの整合性を保つことです。特に以下の点に注意を払う必要があります。

  • スキーマバリデーション:復元されたデータが期待する構造に合致しているかのチェック
  • バージョン管理:アプリケーション更新時の互換性維持
  • エラー時の適切なフォールバック:破損データを検出した際の初期値への復帰

パフォーマンスとユーザー体験のバランス

永続化機能は、パフォーマンスを犠牲にしてまで実装すべきものではありません。以下のような最適化手法を活用することで、ユーザー体験を向上させながら効率的な永続化を実現できます。

  • 変更検出の効率化:不必要な保存処理の回避
  • 非同期処理の活用:UI ブロッキングの防止
  • データ圧縮:ストレージ容量の効率的な使用
  • 選択的永続化:重要なデータのみの保存

実運用における考慮事項

開発環境での動作確認だけでなく、実際のユーザー環境での様々な状況を想定した実装が必要です。

  • ブラウザ間の互換性:各ブラウザのストレージ実装差異への対応
  • プライベートブラウジング:制限された環境での動作保証
  • 容量制限:ユーザーのストレージ使用状況に応じた適切な処理
  • セキュリティ:機密データの適切な取り扱い

これらの考慮事項を踏まえた実装により、信頼性が高く、ユーザーフレンドリーな Web アプリケーションを構築することができます。

開発効率の向上

適切に設計された永続化システムは、開発チーム全体の生産性向上にも寄与します。

  • デバッグの効率化:状態履歴の追跡とエラー分析
  • テストの自動化:予測可能な状態復元による結合テストの安定化
  • チーム間の標準化:共通の永続化パターンによる開発効率向上

Zustand のシンプルさと組み合わせることで、複雑な状態管理を必要とする現代の Web アプリケーションにおいても、保守性と拡張性を兼ね備えた永続化システムを構築することが可能です。

本記事で紹介したテクニックを参考に、皆さまのプロジェクトに最適な永続化戦略を構築していただければと思います。状態の永続化は、優れたユーザー体験を提供するための重要な基盤技術の一つです。適切な実装により、ユーザーにとって価値のあるアプリケーションを開発することができるでしょう。

関連リンク