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

Web アプリケーションを開発していると、ユーザーのテーマ設定や個人設定を保存しておきたいという場面に必ず遭遇しますよね。特にダークモードの切り替えや言語設定など、一度設定したらブラウザを閉じても保持されていてほしい機能は、もはや現代の Web アプリケーションには欠かせません。
従来の方法ではlocalStorage
を直接操作したり、複雑なuseEffect
を組み合わせたりする必要がありましたが、Jotai のatomWithStorage
を使うことで、これらの課題を驚くほどシンプルに解決できるんです。今回は、この強力な機能を使って、実際のプロダクションレベルで使える永続化機能を実装していきましょう。
atomWithStorage の必要性
現代の Web アプリケーションでは、ユーザー体験を向上させるために様々な設定の永続化が求められています。しかし、従来のアプローチには多くの課題がありました。
従来のアプローチの課題
# | 課題 | 影響 |
---|---|---|
1 | localStorage の直接操作 | 型安全性の欠如、同期の複雑さ |
2 | useEffect の多用 | コンポーネントの肥大化、バグの温床 |
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
は、localStorage
のapp-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
は、この課題を解決する強力なツールとして、きっとあなたの開発体験を変えてくれるはずです。
ぜひ実際のプロジェクトで試してみて、その便利さを体感してみてくださいね!
関連リンク
- article
atomWithStorage を使いこなす!Jotai でテーマ(ダークモード)やユーザー設定を永続化する方法
- article
React Hook Formはもう不要?Jotaiで実現する、パフォーマンスを意識したフォーム状態管理術
- article
Zustand と Jotai を比較:軽量ステート管理ライブラリの選び方
- article
Next.js × Jotai で作る SSR 対応のモダン Web アプリケーション
- article
Jotai のプロバイダーパターン完全攻略 - Provider の使い方とベストプラクティス
- article
Jotai と useState の違いを徹底比較 - いつ Jotai を選ぶべき?
- review
もう三日坊主とはサヨナラ!『続ける思考』井上新八
- review
チーム開発が劇的に変わった!『リーダブルコード』Dustin Boswell & Trevor Foucher
- review
アジャイル初心者でも大丈夫!『アジャイルサムライ − 達人開発者への道』Jonathan Rasmusson
- review
人生が作品になる!『自分の中に毒を持て』岡本太郎
- review
体調不良の 99%が解決!『眠れなくなるほど面白い 図解 自律神経の話』小林弘幸著で学ぶ、現代人必須の自律神経コントロール術と人生を変える健康革命
- review
衝撃の事実!『睡眠こそ最強の解決策である』マシュー・ウォーカー著が明かす、99%の人が知らない睡眠の驚くべき真実と人生を変える科学的メカニズム