T-CREATOR

atomWithStorage を使いこなす!Jotai でテーマ(ダークモード)やユーザー設定を永続化する方法

atomWithStorage を使いこなす!Jotai でテーマ(ダークモード)やユーザー設定を永続化する方法

Web アプリケーションを開発していると、ユーザーのテーマ設定や個人設定を保存しておきたいという場面に必ず遭遇しますよね。特にダークモードの切り替えや言語設定など、一度設定したらブラウザを閉じても保持されていてほしい機能は、もはや現代の Web アプリケーションには欠かせません。

従来の方法ではlocalStorageを直接操作したり、複雑なuseEffectを組み合わせたりする必要がありましたが、Jotai のatomWithStorageを使うことで、これらの課題を驚くほどシンプルに解決できるんです。今回は、この強力な機能を使って、実際のプロダクションレベルで使える永続化機能を実装していきましょう。

atomWithStorage の必要性

現代の Web アプリケーションでは、ユーザー体験を向上させるために様々な設定の永続化が求められています。しかし、従来のアプローチには多くの課題がありました。

従来のアプローチの課題

#課題影響
1localStorage の直接操作型安全性の欠如、同期の複雑さ
2useEffect の多用コンポーネントの肥大化、バグの温床
3初期化タイミングの問題フラッシュ現象、UX の悪化
4サーバーサイドレンダリング対応ハイドレーションエラーの発生

これらの課題を解決するために、Jotai ではatomWithStorageという専用の utility が提供されています。

atomWithStorage の優位性

atomWithStorageは、通常の atom と同じように使えながら、自動的にブラウザのストレージと同期してくれる革新的な機能です。

typescriptimport { atomWithStorage } from 'jotai/utils';

// たったこれだけで永続化対応完了!
const themeAtom = atomWithStorage('theme', 'light');

この一行で、状態管理と永続化の両方が実現できてしまうんですね。

基本的な使い方とセットアップ

まずは基本的な環境をセットアップしてみましょう。

必要なパッケージのインストール

bash# Jotaiの基本パッケージとutilsをインストール
yarn add jotai

# TypeScriptの型定義(開発時のみ)
yarn add -D @types/react @types/react-dom

基本的な atom の定義

最初に、シンプルなテーマ管理の atom を作成してみます。

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

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

// テーマ管理用のatom(デフォルトは'system')
export const themeAtom = atomWithStorage<Theme>(
  'app-theme',
  'system'
);

このthemeAtomは、localStorageapp-themeキーと自動的に同期されます。

基本的な使用方法

作成した atom を実際にコンポーネントで使ってみましょう。

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

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

  return (
    <div className='theme-toggle'>
      <button
        onClick={() =>
          setTheme(theme === 'light' ? 'dark' : 'light')
        }
        className='toggle-button'
      >
        {theme === 'light' ? '🌙' : '☀️'}
        {theme === 'light'
          ? 'ダークモード'
          : 'ライトモード'}
      </button>
      <span className='current-theme'>
        現在のテーマ: {theme}
      </span>
    </div>
  );
};

たったこれだけで、ブラウザを再読み込みしてもテーマ設定が保持される機能が完成します!

ダークモードの実装

ここからは、より実用的なダークモード機能を段階的に実装していきます。

基本的なテーマ切り替え機能

まず、CSS 変数を活用したテーマシステムを構築しましょう。

css/* styles/theme.css */
:root {
  /* ライトテーマ */
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --text-primary: #212529;
  --text-secondary: #6c757d;
  --border-color: #dee2e6;
}

[data-theme='dark'] {
  /* ダークテーマ */
  --bg-primary: #1a1a1a;
  --bg-secondary: #2d2d2d;
  --text-primary: #ffffff;
  --text-secondary: #b0b0b0;
  --border-color: #404040;
}

次に、テーマを適用する Provider コンポーネントを作成します。

typescript// components/ThemeProvider.tsx
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { themeAtom } from '../atoms/themeAtom';

export const ThemeProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const theme = useAtomValue(themeAtom);

  useEffect(() => {
    // documentのdata-theme属性を更新
    document.documentElement.setAttribute(
      'data-theme',
      theme
    );
  }, [theme]);

  return <>{children}</>;
};

システム設定との連動

ユーザーのシステム設定(OS のテーマ設定)と連動する機能を追加しましょう。

typescript// hooks/useSystemTheme.ts
import { useEffect, useState } from 'react';

export const useSystemTheme = () => {
  const [systemTheme, setSystemTheme] = useState<
    'light' | 'dark'
  >('light');

  useEffect(() => {
    // システムのテーマ設定を取得
    const mediaQuery = window.matchMedia(
      '(prefers-color-scheme: dark)'
    );

    const updateSystemTheme = (e: MediaQueryListEvent) => {
      setSystemTheme(e.matches ? 'dark' : 'light');
    };

    // 初期値の設定
    setSystemTheme(mediaQuery.matches ? 'dark' : 'light');

    // システム設定の変更を監視
    mediaQuery.addEventListener(
      'change',
      updateSystemTheme
    );

    return () => {
      mediaQuery.removeEventListener(
        'change',
        updateSystemTheme
      );
    };
  }, []);

  return systemTheme;
};

システムテーマと連動する高度な ThemeProvider を作成します。

typescript// components/AdvancedThemeProvider.tsx
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
import { themeAtom } from '../atoms/themeAtom';
import { useSystemTheme } from '../hooks/useSystemTheme';

export const AdvancedThemeProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const theme = useAtomValue(themeAtom);
  const systemTheme = useSystemTheme();

  useEffect(() => {
    // 実際に適用するテーマを決定
    const appliedTheme =
      theme === 'system' ? systemTheme : theme;

    document.documentElement.setAttribute(
      'data-theme',
      appliedTheme
    );

    // メタテーマカラーも更新(モバイル対応)
    const metaThemeColor = document.querySelector(
      'meta[name="theme-color"]'
    );
    if (metaThemeColor) {
      metaThemeColor.setAttribute(
        'content',
        appliedTheme === 'dark' ? '#1a1a1a' : '#ffffff'
      );
    }
  }, [theme, systemTheme]);

  return <>{children}</>;
};

テーマ状態の永続化

SSR 環境でのフラッシュ問題を解決する実装を追加しましょう。

typescript// atoms/themeAtom.ts(改良版)
import { atomWithStorage } from 'jotai/utils';

export type Theme = 'light' | 'dark' | 'system';

// SSR対応のカスタムストレージ
const createNoSSRStorage = () => {
  let storage: Storage | undefined;

  return {
    getItem: (key: string) => {
      // サーバーサイドでは何も返さない
      if (typeof window === 'undefined') return null;

      if (!storage) storage = localStorage;
      return storage.getItem(key);
    },
    setItem: (key: string, value: string) => {
      if (typeof window === 'undefined') return;

      if (!storage) storage = localStorage;
      storage.setItem(key, value);
    },
    removeItem: (key: string) => {
      if (typeof window === 'undefined') return;

      if (!storage) storage = localStorage;
      storage.removeItem(key);
    },
  };
};

export const themeAtom = atomWithStorage<Theme>(
  'app-theme',
  'system',
  createNoSSRStorage()
);

ユーザー設定の永続化

テーマ以外のユーザー設定についても、永続化機能を実装していきましょう。

言語設定の実装

多言語対応のアプリケーションでは、ユーザーの言語設定を保存する必要があります。

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

export type Locale = 'ja' | 'en' | 'ko' | 'zh';

// 言語設定のatom
export const localeAtom = atomWithStorage<Locale>(
  'app-locale',
  'ja'
);

// 言語設定の表示名
export const localeLabels: Record<Locale, string> = {
  ja: '日本語',
  en: 'English',
  ko: '한국어',
  zh: '中文',
};

言語切り替えコンポーネントを作成します。

typescript// components/LanguageSelector.tsx
import { useAtom } from 'jotai';
import {
  localeAtom,
  localeLabels,
  type Locale,
} from '../atoms/localeAtom';

export const LanguageSelector = () => {
  const [locale, setLocale] = useAtom(localeAtom);

  return (
    <div className='language-selector'>
      <label htmlFor='language-select'>言語設定</label>
      <select
        id='language-select'
        value={locale}
        onChange={(e) =>
          setLocale(e.target.value as Locale)
        }
        className='language-select'
      >
        {Object.entries(localeLabels).map(
          ([code, label]) => (
            <option key={code} value={code}>
              {label}
            </option>
          )
        )}
      </select>
    </div>
  );
};

UI 設定(フォントサイズ、レイアウト)の管理

ユーザーのアクセシビリティ向上のため、フォントサイズやレイアウトの設定も永続化しましょう。

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

// UI設定の型定義
export interface UISettings {
  fontSize: 'small' | 'medium' | 'large';
  sidebarWidth: number;
  compactMode: boolean;
  showAnimations: boolean;
}

// デフォルトのUI設定
const defaultUISettings: UISettings = {
  fontSize: 'medium',
  sidebarWidth: 240,
  compactMode: false,
  showAnimations: true,
};

// UI設定用のatom
export const uiSettingsAtom = atomWithStorage<UISettings>(
  'app-ui-settings',
  defaultUISettings
);

// フォントサイズの設定値
export const fontSizeValues = {
  small: '14px',
  medium: '16px',
  large: '18px',
} as const;

UI 設定パネルコンポーネントを作成します。

typescript// components/UISettingsPanel.tsx
import { useAtom } from 'jotai';
import {
  uiSettingsAtom,
  fontSizeValues,
} from '../atoms/uiSettingsAtom';

export const UISettingsPanel = () => {
  const [uiSettings, setUISettings] =
    useAtom(uiSettingsAtom);

  // 個別の設定を更新するヘルパー関数
  const updateSetting = <K extends keyof typeof uiSettings>(
    key: K,
    value: (typeof uiSettings)[K]
  ) => {
    setUISettings((prev) => ({ ...prev, [key]: value }));
  };

  return (
    <div className='ui-settings-panel'>
      <h3>UI設定</h3>

      {/* フォントサイズ設定 */}
      <div className='setting-group'>
        <label>フォントサイズ</label>
        <select
          value={uiSettings.fontSize}
          onChange={(e) =>
            updateSetting('fontSize', e.target.value as any)
          }
        >
          <option value='small'>小 (14px)</option>
          <option value='medium'>中 (16px)</option>
          <option value='large'>大 (18px)</option>
        </select>
      </div>

      {/* サイドバー幅設定 */}
      <div className='setting-group'>
        <label>
          サイドバー幅: {uiSettings.sidebarWidth}px
        </label>
        <input
          type='range'
          min='200'
          max='400'
          value={uiSettings.sidebarWidth}
          onChange={(e) =>
            updateSetting(
              'sidebarWidth',
              Number(e.target.value)
            )
          }
        />
      </div>

      {/* コンパクトモード */}
      <div className='setting-group'>
        <label>
          <input
            type='checkbox'
            checked={uiSettings.compactMode}
            onChange={(e) =>
              updateSetting('compactMode', e.target.checked)
            }
          />
          コンパクトモード
        </label>
      </div>

      {/* アニメーション設定 */}
      <div className='setting-group'>
        <label>
          <input
            type='checkbox'
            checked={uiSettings.showAnimations}
            onChange={(e) =>
              updateSetting(
                'showAnimations',
                e.target.checked
              )
            }
          />
          アニメーションを表示
        </label>
      </div>
    </div>
  );
};

複合設定オブジェクトの扱い方

すべての設定を一元管理する統合設定システムを構築してみましょう。

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

// 統合設定の型定義
export interface AppSettings {
  theme: 'light' | 'dark' | 'system';
  locale: 'ja' | 'en' | 'ko' | 'zh';
  ui: {
    fontSize: 'small' | 'medium' | 'large';
    sidebarWidth: number;
    compactMode: boolean;
    showAnimations: boolean;
  };
  privacy: {
    analyticsEnabled: boolean;
    crashReportingEnabled: boolean;
  };
  notifications: {
    email: boolean;
    push: boolean;
    sound: boolean;
  };
}

// デフォルト設定
const defaultAppSettings: AppSettings = {
  theme: 'system',
  locale: 'ja',
  ui: {
    fontSize: 'medium',
    sidebarWidth: 240,
    compactMode: false,
    showAnimations: true,
  },
  privacy: {
    analyticsEnabled: true,
    crashReportingEnabled: true,
  },
  notifications: {
    email: true,
    push: false,
    sound: true,
  },
};

// メインのAppSettings atom
export const appSettingsAtom = atomWithStorage<AppSettings>(
  'app-settings',
  defaultAppSettings
);

// 各セクションの派生atom(パフォーマンス最適化)
export const themeSettingAtom = atom(
  (get) => get(appSettingsAtom).theme,
  (get, set, newTheme: AppSettings['theme']) => {
    const current = get(appSettingsAtom);
    set(appSettingsAtom, { ...current, theme: newTheme });
  }
);

export const uiSettingsPartialAtom = atom(
  (get) => get(appSettingsAtom).ui,
  (get, set, newUI: AppSettings['ui']) => {
    const current = get(appSettingsAtom);
    set(appSettingsAtom, { ...current, ui: newUI });
  }
);

実践的なテクニック

ここからは、プロダクション環境で使える実践的なテクニックを紹介します。

TypeScript 型定義の活用

型安全性を確保するための高度な TypeScript 活用法を見ていきましょう。

typescript// types/settings.ts
// 設定の更新タイプをより厳密に定義
export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? DeepPartial<T[P]>
    : T[P];
};

// 設定のキーパスを型安全に定義
export type SettingsPath<
  T,
  K extends keyof T = keyof T
> = K extends string
  ? T[K] extends object
    ? `${K}.${SettingsPath<T[K]>}`
    : K
  : never;

// 型安全な設定更新関数
export const createSettingsUpdater = <
  T extends Record<string, any>
>(
  settingsAtom: WritableAtom<T, [T], void>
) => {
  return {
    // 部分更新(Deep merge)
    updatePartial: (
      get: Getter,
      set: Setter,
      updates: DeepPartial<T>
    ) => {
      const current = get(settingsAtom);
      const merged = deepMerge(current, updates);
      set(settingsAtom, merged);
    },

    // 特定のキーをリセット
    resetKey: (get: Getter, set: Setter, key: keyof T) => {
      const current = get(settingsAtom);
      const defaultValue = getDefaultValue(key); // 実装は省略
      set(settingsAtom, {
        ...current,
        [key]: defaultValue,
      });
    },
  };
};

デフォルト値とエラーハンドリング

ストレージから読み取った値が不正な場合の処理を実装しましょう。

typescript// utils/storageUtils.ts
import { z } from 'zod'; // バリデーションライブラリ

// 設定のスキーマ定義
const AppSettingsSchema = z.object({
  theme: z.enum(['light', 'dark', 'system']),
  locale: z.enum(['ja', 'en', 'ko', 'zh']),
  ui: z.object({
    fontSize: z.enum(['small', 'medium', 'large']),
    sidebarWidth: z.number().min(200).max(400),
    compactMode: z.boolean(),
    showAnimations: z.boolean(),
  }),
});

// 安全なストレージ実装
export const createSafeStorage = <T>(
  schema: z.ZodSchema<T>,
  defaultValue: T
) => {
  return {
    getItem: (key: string): string | null => {
      try {
        if (typeof window === 'undefined') return null;

        const stored = localStorage.getItem(key);
        if (!stored) return JSON.stringify(defaultValue);

        // 保存された値をバリデーション
        const parsed = JSON.parse(stored);
        const validated = schema.parse(parsed);

        return JSON.stringify(validated);
      } catch (error) {
        console.warn(
          `設定の読み込みに失敗しました: ${key}`,
          error
        );
        // デフォルト値を返す
        return JSON.stringify(defaultValue);
      }
    },

    setItem: (key: string, value: string) => {
      try {
        if (typeof window === 'undefined') return;

        // 保存前にバリデーション
        const parsed = JSON.parse(value);
        const validated = schema.parse(parsed);

        localStorage.setItem(
          key,
          JSON.stringify(validated)
        );
      } catch (error) {
        console.error(
          `設定の保存に失敗しました: ${key}`,
          error
        );
      }
    },

    removeItem: (key: string) => {
      if (typeof window === 'undefined') return;
      localStorage.removeItem(key);
    },
  };
};

// 使用例
export const safeAppSettingsAtom = atomWithStorage(
  'app-settings',
  defaultAppSettings,
  createSafeStorage(AppSettingsSchema, defaultAppSettings)
);

ストレージ容量の最適化

大量の設定データを効率的に管理するテクニックをご紹介します。

typescript// utils/compressionUtils.ts
// 設定データの圧縮・最適化

export const createOptimizedStorage = () => {
  return {
    getItem: (key: string): string | null => {
      if (typeof window === 'undefined') return null;

      const stored = localStorage.getItem(key);
      if (!stored) return null;

      try {
        // Base64デコード + JSON展開
        const decoded = atob(stored);
        return decoded;
      } catch {
        // 圧縮されていない場合はそのまま返す
        return stored;
      }
    },

    setItem: (key: string, value: string) => {
      if (typeof window === 'undefined') return;

      try {
        // 一定サイズ以上の場合のみ圧縮
        if (value.length > 1000) {
          const compressed = btoa(value);
          localStorage.setItem(key, compressed);
        } else {
          localStorage.setItem(key, value);
        }
      } catch (error) {
        console.error('ストレージ容量エラー:', error);
        // 古い設定を削除して再試行
        cleanupOldSettings();
        localStorage.setItem(key, value);
      }
    },

    removeItem: (key: string) => {
      if (typeof window === 'undefined') return;
      localStorage.removeItem(key);
    },
  };
};

// 古い設定のクリーンアップ
const cleanupOldSettings = () => {
  const keysToRemove: string[] = [];

  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i);
    if (
      key &&
      key.startsWith('app-') &&
      key.includes('-v1')
    ) {
      keysToRemove.push(key);
    }
  }

  keysToRemove.forEach((key) =>
    localStorage.removeItem(key)
  );
};

// 使用状況の監視
export const monitorStorageUsage = () => {
  if (typeof window === 'undefined') return;

  const used = JSON.stringify(localStorage).length;
  const quota = 5 * 1024 * 1024; // 5MB(概算)
  const usage = (used / quota) * 100;

  console.log(`LocalStorage使用率: ${usage.toFixed(1)}%`);

  if (usage > 80) {
    console.warn('LocalStorageの使用量が80%を超えています');
  }
};

設定のインポート・エクスポート機能も実装してみましょう。

typescript// hooks/useSettingsManager.ts
import { useAtom } from 'jotai';
import { appSettingsAtom } from '../atoms/appSettingsAtom';

export const useSettingsManager = () => {
  const [settings, setSettings] = useAtom(appSettingsAtom);

  // 設定のエクスポート
  const exportSettings = () => {
    const dataStr = JSON.stringify(settings, null, 2);
    const dataUri =
      'data:application/json;charset=utf-8,' +
      encodeURIComponent(dataStr);

    const exportFileDefaultName = `app-settings-${new Date()
      .toISOString()
      .slice(0, 10)}.json`;

    const linkElement = document.createElement('a');
    linkElement.setAttribute('href', dataUri);
    linkElement.setAttribute(
      'download',
      exportFileDefaultName
    );
    linkElement.click();
  };

  // 設定のインポート
  const importSettings = (file: File) => {
    return new Promise<void>((resolve, reject) => {
      const reader = new FileReader();

      reader.onload = (e) => {
        try {
          const importedSettings = JSON.parse(
            e.target?.result as string
          );
          // バリデーション後に設定を更新
          setSettings(importedSettings);
          resolve();
        } catch (error) {
          reject(
            new Error(
              '設定ファイルの形式が正しくありません'
            )
          );
        }
      };

      reader.onerror = () =>
        reject(
          new Error('ファイルの読み込みに失敗しました')
        );
      reader.readAsText(file);
    });
  };

  // 設定のリセット
  const resetSettings = () => {
    setSettings(defaultAppSettings);
  };

  return {
    settings,
    exportSettings,
    importSettings,
    resetSettings,
  };
};

まとめ

atomWithStorageを活用することで、従来の複雑な永続化処理を驚くほどシンプルに実装できることがお分かりいただけたでしょうか。

今回ご紹介した内容をまとめると:

#機能特徴
1基本的なテーマ管理シンプルな一行実装
2システム連動OS 設定との自動同期
3複合設定管理型安全な構造化データ
4エラーハンドリングバリデーション付き保存
5最適化テクニック圧縮・クリーンアップ機能

特に重要なのは、単純な LocalStorage 操作から脱却し、型安全で保守性の高い設定管理システムを構築できる点です。これにより、ユーザーエクスペリエンスを大幅に向上させながら、開発者にとっても扱いやすいコードベースを維持できます。

現代の Web アプリケーション開発では、ユーザーの設定や状態を適切に永続化することが必須となっています。atomWithStorageは、この課題を解決する強力なツールとして、きっとあなたの開発体験を変えてくれるはずです。

ぜひ実際のプロジェクトで試してみて、その便利さを体感してみてくださいね!

関連リンク