Jotai の永続化戦略比較:atomWithStorage/Redux 風/カスタム Storage
React アプリケーションで状態管理を行う際、ページをリロードしても状態を保持したいというニーズは非常に一般的です。特に、ユーザーの設定やフォーム入力、認証情報などは永続化が必須となるでしょう。
Jotai は軽量でシンプルな状態管理ライブラリですが、状態の永続化には複数のアプローチが存在します。本記事では、atomWithStorage を使った公式の方法、Redux 風のミドルウェアパターン、そしてカスタム Storage 実装の 3 つの戦略を徹底比較していきます。
それぞれの戦略には明確な特徴があり、プロジェクトの要件に応じて最適な選択が変わってきます。実装の難易度、型安全性、パフォーマンス、拡張性といった観点から、どの戦略を選ぶべきか判断できるようになりましょう。
背景
Jotai による状態管理の特徴
Jotai は Recoil にインスパイアされた、Atomic な状態管理ライブラリです。Redux や Zustand と比較して、より小さな粒度で状態を管理できる点が大きな特徴となっています。
atom という最小単位で状態を定義し、それらを組み合わせることで複雑な状態管理を実現します。Provider を使わずとも動作する点や、TypeScript との相性の良さから、多くの開発者に支持されているライブラリですね。
状態永続化の必要性
Web アプリケーションでは、以下のようなケースで状態の永続化が求められます。
| # | ユースケース | 永続化の目的 |
|---|---|---|
| 1 | ユーザー設定 | テーマ、言語、レイアウト設定を保存 |
| 2 | フォーム入力 | 未送信データの保護、下書き保存 |
| 3 | 認証情報 | トークン、セッション情報の維持 |
| 4 | ショッピングカート | 商品選択状態の保持 |
| 5 | UI 状態 | サイドバーの開閉、タブの選択状態 |
これらの状態をメモリ上だけで管理すると、ページをリロードした瞬間にすべて失われてしまいます。そのため、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 では正しく処理できません。
| # | データ型 | 問題点 | 対処方法 |
|---|---|---|---|
| 1 | Date オブジェクト | 文字列に変換される | カスタムシリアライザ |
| 2 | Map / Set | 空オブジェクトになる | Array への変換 |
| 3 | 循環参照 | エラーが発生 | 参照の除去 |
| 4 | Function | undefined になる | 保存対象から除外 |
| 5 | BigInt | エラーが発生 | 文字列変換 |
また、大きなオブジェクトを頻繁に保存すると、メインスレッドをブロックしてしまう可能性があります。
型安全性の維持
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)を超えた場合
- プライベートブラウジングモードで制限が厳しい場合
解決方法:
- 古いデータを削除して容量を確保
- より容量の大きい IndexedDB への移行を検討
- データ圧縮の実装
- ユーザーへのエラー通知と代替手段の提示
解決策
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 つの戦略の比較
下表は、各戦略の特徴を比較したものです。プロジェクトの要件に応じて選択しましょう。
| # | 観点 | atomWithStorage | Redux 風 | カスタム 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 から始めて、必要に応じてより高度な戦略に移行していくのが良いアプローチですね。
関連リンク
articleJotai の永続化戦略比較:atomWithStorage/Redux 風/カスタム Storage
articleJotai 非同期で Suspense が発火しない問題の切り分けガイド
articleJotai でフォームを分割統治:フィールド粒度の atom 設計と検証戦略
articleJotai 非同期チートシート:async atom/loadable/suspense の使い分け
articlejotai × TypeScript 型推論を極める実戦のための環境設定術
articleJotai のリアクティブ思考法:コンポーネントから状態を切り離す設計哲学
articleNext.js を Bun で動かす開発環境:起動速度・互換性・落とし穴
articleObsidian Properties 速見表:型・表示名・テンプレ連携の実例カタログ
articleNuxt useHead/useSeoMeta 定番スニペット集:OGP/構造化データ/国際化メタ
articleMermaid で描ける図の種類カタログ:flowchart/class/state/journey/timeline ほか完全整理
articleMCP サーバーを活用した AI チャットボット構築:実用的な事例と実装
articleNginx 変数 100 選:$request_id/$upstream_status/$ssl_protocol ほか即戦力まとめ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来