Zustand で状態をローカルストレージに保存する:persist の導入と注意点

モダンな Web アプリケーション開発において、ユーザーの操作状態や設定を保持することは、優れたユーザーエクスペリエンスを提供するために欠かせない機能です。
ページをリロードするたびに設定がリセットされる、フォームに入力した内容が消える、ダークモード設定が元に戻ってしまう。このような体験は、ユーザーにとって大きなストレスとなります。
Zustand の persist ミドルウェアは、こうした課題を解決するための強力な機能です。状態管理とローカルストレージの連携を簡潔に実現し、開発者の負担を大幅に軽減してくれます。今回は、persist の基本的な使い方から実用的な活用例まで、体系的にご紹介していきましょう。
背景
ローカルストレージによる状態永続化の必要性
現代の Web アプリケーションは、デスクトップアプリケーションと同等のユーザー体験が求められています。ユーザーが一度設定した内容や作業中のデータは、ブラウザを閉じても次回アクセス時に復元されることが期待されます。
特に以下のような場面で、状態の永続化は重要な役割を果たします:
シーン | 永続化が必要な理由 | 具体例 |
---|---|---|
ユーザー設定 | 個人の好みを記憶 | テーマ、言語、表示設定 |
作業状態 | 作業継続性の確保 | フォーム入力、編集内容 |
ユーザー認証 | セッション管理 | ログイン状態、権限情報 |
アプリケーション状態 | 一貫した体験提供 | フィルター設定、ソート順 |
Zustand における persist ミドルウェアの位置づけ
Zustand は軽量でシンプルな状態管理ライブラリですが、persist ミドルウェアを組み合わせることで、状態の永続化機能を簡単に追加できます。
typescript// persist を使わない通常のストア
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
}
// ページリロード時に count は 0 にリセットされる
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () =>
set((state) => ({ count: state.count + 1 })),
decrement: () =>
set((state) => ({ count: state.count - 1 })),
}));
上記のような通常のストアでは、ページをリロードするたびに初期状態に戻ってしまいます。persist ミドルウェアを使用することで、この問題を解決できます。
従来の localStorage 直接操作との比較
従来の方法では、localStorage への読み書きを手動で実装する必要がありました。
typescript// 従来の手動実装(アンチパターン)
const useManualStore = create<CounterState>((set, get) => ({
count: parseInt(localStorage.getItem('counter') || '0'),
increment: () => {
const newCount = get().count + 1;
set({ count: newCount });
localStorage.setItem('counter', newCount.toString());
},
decrement: () => {
const newCount = get().count - 1;
set({ count: newCount });
localStorage.setItem('counter', newCount.toString());
},
}));
この手動実装には以下の問題があります:
- 冗長性: 各アクションで localStorage の更新を手動で行う必要
- エラー処理不備: localStorage が利用できない環境での考慮不足
- 型安全性の欠如: シリアライゼーション/デシリアライゼーションでの型情報損失
- パフォーマンス問題: 毎回同期的に localStorage にアクセス
persist ミドルウェアを使用することで、これらの問題をエレガントに解決できます。
課題
ページリロード時の状態消失問題
Web アプリケーションの状態は通常、メモリ上に保存されているため、ページリロードやブラウザの再起動で消失してしまいます。
typescript// 問題のあるパターン:状態が消失する
const useAppStore = create((set) => ({
userPreferences: {
theme: 'light',
language: 'ja',
notifications: true,
},
// ユーザーがダークモードに切り替えても
// ページリロード時に 'light' に戻ってしまう
setTheme: (theme: string) =>
set((state) => ({
userPreferences: { ...state.userPreferences, theme },
})),
}));
この問題により、ユーザーは毎回同じ設定を繰り返し行う必要があり、ユーザーエクスペリエンスが大幅に損なわれます。
複雑な状態のシリアライゼーション課題
JavaScript のオブジェクトをローカルストレージに保存するためには、JSON 形式への変換(シリアライゼーション)が必要です。しかし、すべてのデータ型が JSON 形式でサポートされているわけではありません。
typescript// シリアライゼーションで問題となるデータ型
interface ComplexState {
// JSON で表現できるもの
userName: string;
settings: Record<string, any>;
// JSON で表現できないもの
createdAt: Date; // Date オブジェクト
userAction: () => void; // 関数
userData: Map<string, any>; // Map オブジェクト
fileData: File; // File オブジェクト
}
これらの複雑なデータ型を適切に処理するには、カスタムシリアライゼーション戦略が必要になります。
ブラウザ間・デバイス間での状態同期の困難
ローカルストレージはブラウザ固有の機能であり、異なるブラウザやデバイス間での状態共有はできません。
typescript// ブラウザ A で設定した状態
localStorage.setItem(
'userSettings',
JSON.stringify({
theme: 'dark',
language: 'en',
})
);
// ブラウザ B では別の localStorage 領域のため
// 上記の設定にアクセスできない
また、プライベートブラウジングモードや、ストレージ容量制限など、環境固有の制約も考慮する必要があります。
解決策
persist ミドルウェアの基本導入方法
persist ミドルウェアを使用することで、状態の永続化を簡単に実現できます。
typescriptimport { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UserState {
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
language: string;
};
updateName: (name: string) => void;
updatePreferences: (
preferences: Partial<UserState['preferences']>
) => void;
}
// persist ミドルウェアを適用したストア
const useUserStore = create<UserState>()(
persist(
(set) => ({
// 初期状態
name: '',
email: '',
preferences: {
theme: 'light',
language: 'ja',
},
// アクション
updateName: (name) => set({ name }),
updatePreferences: (newPreferences) =>
set((state) => ({
preferences: {
...state.preferences,
...newPreferences,
},
})),
}),
{
name: 'user-storage', // localStorage のキー名
}
)
);
この基本的な実装により、状態の変更が自動的にローカルストレージに保存され、ページリロード時に復元されます。
TypeScript での型安全な実装
TypeScript を使用する際は、型情報を適切に保持しながら persist を実装することが重要です。
typescript// 型安全な persist 実装
interface StrictUserState {
id: number;
profile: {
name: string;
avatar?: string;
preferences: {
theme: 'light' | 'dark' | 'system';
notifications: {
email: boolean;
push: boolean;
marketing: boolean;
};
};
};
isOnline: boolean;
lastLoginAt: string; // ISO 文字列として保存
}
interface StrictUserActions {
updateProfile: (
profile: Partial<StrictUserState['profile']>
) => void;
setOnlineStatus: (isOnline: boolean) => void;
updateLastLogin: () => void;
clearUserData: () => void;
}
type StrictUserStore = StrictUserState & StrictUserActions;
const useStrictUserStore = create<StrictUserStore>()(
persist(
(set, get) => ({
// 型が厳密に定義された初期状態
id: 0,
profile: {
name: '',
preferences: {
theme: 'system',
notifications: {
email: true,
push: true,
marketing: false,
},
},
},
isOnline: false,
lastLoginAt: new Date().toISOString(),
// 型安全なアクション
updateProfile: (profileUpdates) =>
set((state) => ({
profile: { ...state.profile, ...profileUpdates },
})),
setOnlineStatus: (isOnline) => set({ isOnline }),
updateLastLogin: () =>
set({ lastLoginAt: new Date().toISOString() }),
clearUserData: () =>
set({
id: 0,
profile: {
name: '',
preferences: {
theme: 'system',
notifications: {
email: true,
push: true,
marketing: false,
},
},
},
isOnline: false,
}),
}),
{
name: 'strict-user-storage',
// 型安全なシリアライゼーション
serialize: (state) => JSON.stringify(state),
deserialize: (str) =>
JSON.parse(str) as StrictUserStore,
}
)
);
設定オプション(name、storage、partialize)の活用
persist ミドルウェアには、様々な設定オプションが用意されています。
name オプション:ストレージキーの指定
typescriptconst useConfigStore = create()(
persist(
(set) => ({
apiEndpoint: '',
timeout: 5000,
}),
{
name: 'app-config', // localStorage['app-config'] に保存
}
)
);
storage オプション:カスタムストレージの指定
typescript// sessionStorage を使用
const useSessionStore = create()(
persist(
(set) => ({
temporaryData: '',
}),
{
name: 'session-data',
storage: {
getItem: (name) => sessionStorage.getItem(name),
setItem: (name, value) =>
sessionStorage.setItem(name, value),
removeItem: (name) =>
sessionStorage.removeItem(name),
},
}
)
);
// IndexedDB を使用(大容量データ対応)
import { get, set, del } from 'idb-keyval';
const useIndexedDBStore = create()(
persist(
(set) => ({
largeDataSet: [],
}),
{
name: 'large-data',
storage: {
getItem: async (name) => {
return (await get(name)) || null;
},
setItem: async (name, value) => {
await set(name, value);
},
removeItem: async (name) => {
await del(name);
},
},
}
)
);
partialize オプション:部分的な永続化
typescriptinterface AppState {
// 永続化したい状態
userSettings: {
theme: string;
language: string;
};
// 永続化したくない状態
isLoading: boolean;
errorMessage: string;
temporaryData: any[];
}
const useAppStore = create<AppState>()(
persist(
(set) => ({
userSettings: {
theme: 'light',
language: 'ja',
},
isLoading: false,
errorMessage: '',
temporaryData: [],
// アクション省略...
}),
{
name: 'app-storage',
// userSettings のみを永続化
partialize: (state) => ({
userSettings: state.userSettings,
}),
}
)
);
具体例
ユーザー設定の永続化実装
実際のアプリケーションでよく使用されるユーザー設定の永続化を実装してみましょう。
typescriptinterface UserPreferences {
appearance: {
theme: 'light' | 'dark' | 'system';
fontSize: 'small' | 'medium' | 'large';
compactMode: boolean;
};
accessibility: {
highContrast: boolean;
reducedMotion: boolean;
screenReader: boolean;
};
notifications: {
desktop: boolean;
email: boolean;
marketing: boolean;
};
privacy: {
analytics: boolean;
cookies: boolean;
};
}
interface UserPreferencesStore extends UserPreferences {
updateAppearance: (
appearance: Partial<UserPreferences['appearance']>
) => void;
updateAccessibility: (
accessibility: Partial<UserPreferences['accessibility']>
) => void;
updateNotifications: (
notifications: Partial<UserPreferences['notifications']>
) => void;
updatePrivacy: (
privacy: Partial<UserPreferences['privacy']>
) => void;
resetToDefaults: () => void;
exportSettings: () => string;
importSettings: (settings: string) => boolean;
}
const defaultPreferences: UserPreferences = {
appearance: {
theme: 'system',
fontSize: 'medium',
compactMode: false,
},
accessibility: {
highContrast: false,
reducedMotion: false,
screenReader: false,
},
notifications: {
desktop: true,
email: false,
marketing: false,
},
privacy: {
analytics: true,
cookies: true,
},
};
export const useUserPreferencesStore =
create<UserPreferencesStore>()(
persist(
(set, get) => ({
...defaultPreferences,
updateAppearance: (appearance) =>
set((state) => ({
appearance: {
...state.appearance,
...appearance,
},
})),
updateAccessibility: (accessibility) =>
set((state) => ({
accessibility: {
...state.accessibility,
...accessibility,
},
})),
updateNotifications: (notifications) =>
set((state) => ({
notifications: {
...state.notifications,
...notifications,
},
})),
updatePrivacy: (privacy) =>
set((state) => ({
privacy: { ...state.privacy, ...privacy },
})),
resetToDefaults: () => set(defaultPreferences),
exportSettings: () => {
const state = get();
return JSON.stringify({
appearance: state.appearance,
accessibility: state.accessibility,
notifications: state.notifications,
privacy: state.privacy,
});
},
importSettings: (settings) => {
try {
const parsedSettings = JSON.parse(
settings
) as UserPreferences;
set(parsedSettings);
return true;
} catch {
return false;
}
},
}),
{
name: 'user-preferences',
partialize: (state) => ({
appearance: state.appearance,
accessibility: state.accessibility,
notifications: state.notifications,
privacy: state.privacy,
}),
}
)
);
ショッピングカート状態の保存
EC サイトでのショッピングカート機能を persist で実装します。
typescriptinterface CartItem {
id: string;
productId: string;
name: string;
price: number;
quantity: number;
variant?: {
size?: string;
color?: string;
};
addedAt: string;
}
interface CartState {
items: CartItem[];
appliedCoupons: string[];
shippingMethod?: 'standard' | 'express' | 'overnight';
estimatedTotal: number;
}
interface CartActions {
addItem: (item: Omit<CartItem, 'addedAt'>) => void;
removeItem: (itemId: string) => void;
updateQuantity: (
itemId: string,
quantity: number
) => void;
applyCoupon: (couponCode: string) => void;
removeCoupon: (couponCode: string) => void;
setShippingMethod: (
method: CartState['shippingMethod']
) => void;
clearCart: () => void;
calculateTotal: () => void;
}
type CartStore = CartState & CartActions;
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
appliedCoupons: [],
estimatedTotal: 0,
addItem: (itemData) => {
const newItem: CartItem = {
...itemData,
addedAt: new Date().toISOString(),
};
set((state) => {
const existingItemIndex = state.items.findIndex(
(item) =>
item.productId === newItem.productId &&
JSON.stringify(item.variant) ===
JSON.stringify(newItem.variant)
);
if (existingItemIndex >= 0) {
// 既存アイテムの数量を更新
const updatedItems = [...state.items];
updatedItems[existingItemIndex].quantity +=
newItem.quantity;
return { items: updatedItems };
} else {
// 新しいアイテムを追加
return { items: [...state.items, newItem] };
}
});
get().calculateTotal();
},
removeItem: (itemId) => {
set((state) => ({
items: state.items.filter(
(item) => item.id !== itemId
),
}));
get().calculateTotal();
},
updateQuantity: (itemId, quantity) => {
if (quantity <= 0) {
get().removeItem(itemId);
return;
}
set((state) => ({
items: state.items.map((item) =>
item.id === itemId
? { ...item, quantity }
: item
),
}));
get().calculateTotal();
},
applyCoupon: (couponCode) => {
set((state) => ({
appliedCoupons: [
...state.appliedCoupons,
couponCode,
],
}));
get().calculateTotal();
},
removeCoupon: (couponCode) => {
set((state) => ({
appliedCoupons: state.appliedCoupons.filter(
(code) => code !== couponCode
),
}));
get().calculateTotal();
},
setShippingMethod: (method) => {
set({ shippingMethod: method });
get().calculateTotal();
},
clearCart: () => {
set({
items: [],
appliedCoupons: [],
shippingMethod: undefined,
estimatedTotal: 0,
});
},
calculateTotal: () => {
const state = get();
const itemsTotal = state.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
// 配送料の計算(簡単な例)
const shippingCost =
state.shippingMethod === 'express'
? 500
: state.shippingMethod === 'overnight'
? 1000
: 0;
// クーポン割引の計算(簡単な例)
const discount = state.appliedCoupons.length * 100;
const estimatedTotal = Math.max(
0,
itemsTotal + shippingCost - discount
);
set({ estimatedTotal });
},
}),
{
name: 'shopping-cart',
// アイテムの有効期限チェック(7日間)
onRehydrateStorage: () => (state) => {
if (state) {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const validItems = state.items.filter(
(item) => new Date(item.addedAt) > sevenDaysAgo
);
if (validItems.length !== state.items.length) {
state.items = validItems;
state.calculateTotal();
}
}
},
}
)
);
フォーム入力データの一時保存とバリデーション
長大なフォームでの入力データを自動保存する機能を実装します。
typescriptinterface FormField {
value: string;
isValid: boolean;
errorMessage?: string;
lastModified: string;
}
interface ContactFormState {
fields: {
firstName: FormField;
lastName: FormField;
email: FormField;
phone: FormField;
company: FormField;
message: FormField;
};
currentStep: number;
totalSteps: number;
isSubmitting: boolean;
isDraft: boolean;
}
interface ContactFormActions {
updateField: (
fieldName: keyof ContactFormState['fields'],
value: string
) => void;
validateField: (
fieldName: keyof ContactFormState['fields']
) => void;
validateAllFields: () => boolean;
nextStep: () => void;
prevStep: () => void;
saveDraft: () => void;
clearDraft: () => void;
submitForm: () => Promise<boolean>;
}
type ContactFormStore = ContactFormState &
ContactFormActions;
const createEmptyField = (): FormField => ({
value: '',
isValid: false,
lastModified: new Date().toISOString(),
});
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validatePhone = (phone: string): boolean => {
const phoneRegex = /^[\d\-\+\(\)\s]+$/;
return (
phoneRegex.test(phone) &&
phone.replace(/\D/g, '').length >= 10
);
};
export const useContactFormStore =
create<ContactFormStore>()(
persist(
(set, get) => ({
fields: {
firstName: createEmptyField(),
lastName: createEmptyField(),
email: createEmptyField(),
phone: createEmptyField(),
company: createEmptyField(),
message: createEmptyField(),
},
currentStep: 1,
totalSteps: 3,
isSubmitting: false,
isDraft: true,
updateField: (fieldName, value) => {
set((state) => ({
fields: {
...state.fields,
[fieldName]: {
...state.fields[fieldName],
value,
lastModified: new Date().toISOString(),
},
},
isDraft: true,
}));
// リアルタイムバリデーション
setTimeout(
() => get().validateField(fieldName),
100
);
},
validateField: (fieldName) => {
set((state) => {
const field = state.fields[fieldName];
let isValid = false;
let errorMessage: string | undefined;
switch (fieldName) {
case 'firstName':
case 'lastName':
isValid = field.value.trim().length >= 2;
if (!isValid)
errorMessage =
'2文字以上で入力してください';
break;
case 'email':
isValid = validateEmail(field.value);
if (!isValid)
errorMessage =
'正しいメールアドレスを入力してください';
break;
case 'phone':
isValid = validatePhone(field.value);
if (!isValid)
errorMessage =
'正しい電話番号を入力してください';
break;
case 'company':
isValid = field.value.trim().length > 0;
if (!isValid)
errorMessage = '会社名を入力してください';
break;
case 'message':
isValid = field.value.trim().length >= 10;
if (!isValid)
errorMessage =
'10文字以上でご記入ください';
break;
}
return {
fields: {
...state.fields,
[fieldName]: {
...field,
isValid,
errorMessage,
},
},
};
});
},
validateAllFields: () => {
const state = get();
Object.keys(state.fields).forEach((fieldName) => {
get().validateField(
fieldName as keyof ContactFormState['fields']
);
});
const updatedState = get();
return Object.values(updatedState.fields).every(
(field) => field.isValid
);
},
nextStep: () => {
set((state) => ({
currentStep: Math.min(
state.currentStep + 1,
state.totalSteps
),
}));
},
prevStep: () => {
set((state) => ({
currentStep: Math.max(state.currentStep - 1, 1),
}));
},
saveDraft: () => {
set({ isDraft: true });
// persist により自動的にローカルストレージに保存される
},
clearDraft: () => {
set({
fields: {
firstName: createEmptyField(),
lastName: createEmptyField(),
email: createEmptyField(),
phone: createEmptyField(),
company: createEmptyField(),
message: createEmptyField(),
},
currentStep: 1,
isDraft: false,
});
},
submitForm: async () => {
if (!get().validateAllFields()) {
return false;
}
set({ isSubmitting: true });
try {
const state = get();
const formData = Object.entries(
state.fields
).reduce(
(acc, [key, field]) => ({
...acc,
[key]: field.value,
}),
{}
);
// API 呼び出し(実装例)
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
get().clearDraft();
return true;
}
return false;
} catch (error) {
console.error('Form submission error:', error);
return false;
} finally {
set({ isSubmitting: false });
}
},
}),
{
name: 'contact-form-draft',
partialize: (state) => ({
fields: state.fields,
currentStep: state.currentStep,
isDraft: state.isDraft,
}),
}
)
);
ダークモード切り替えの記憶機能
システム設定との連携も考慮したダークモード管理を実装します。
typescripttype ThemeMode = 'light' | 'dark' | 'system';
interface ThemeState {
mode: ThemeMode;
systemPreference: 'light' | 'dark';
currentTheme: 'light' | 'dark';
customColors?: {
primary: string;
secondary: string;
accent: string;
};
}
interface ThemeActions {
setMode: (mode: ThemeMode) => void;
updateSystemPreference: (
preference: 'light' | 'dark'
) => void;
setCustomColors: (
colors: ThemeState['customColors']
) => void;
resetCustomColors: () => void;
applyTheme: () => void;
}
type ThemeStore = ThemeState & ThemeActions;
export const useThemeStore = create<ThemeStore>()(
persist(
(set, get) => ({
mode: 'system',
systemPreference: 'light',
currentTheme: 'light',
setMode: (mode) => {
set({ mode });
get().applyTheme();
},
updateSystemPreference: (preference) => {
set({ systemPreference: preference });
if (get().mode === 'system') {
get().applyTheme();
}
},
setCustomColors: (colors) => {
set({ customColors: colors });
get().applyTheme();
},
resetCustomColors: () => {
set({ customColors: undefined });
get().applyTheme();
},
applyTheme: () => {
const state = get();
const newTheme =
state.mode === 'system'
? state.systemPreference
: state.mode;
set({ currentTheme: newTheme });
// DOM の更新
document.documentElement.setAttribute(
'data-theme',
newTheme
);
// カスタムカラーの適用
if (state.customColors) {
const root = document.documentElement;
root.style.setProperty(
'--color-primary',
state.customColors.primary
);
root.style.setProperty(
'--color-secondary',
state.customColors.secondary
);
root.style.setProperty(
'--color-accent',
state.customColors.accent
);
} else {
// デフォルトカラーにリセット
const root = document.documentElement;
root.style.removeProperty('--color-primary');
root.style.removeProperty('--color-secondary');
root.style.removeProperty('--color-accent');
}
},
}),
{
name: 'theme-preferences',
partialize: (state) => ({
mode: state.mode,
customColors: state.customColors,
}),
onRehydrateStorage: () => (state) => {
if (state) {
// システム設定の初期化
const mediaQuery = window.matchMedia(
'(prefers-color-scheme: dark)'
);
state.updateSystemPreference(
mediaQuery.matches ? 'dark' : 'light'
);
// システム設定変更の監視
mediaQuery.addListener((e) => {
state.updateSystemPreference(
e.matches ? 'dark' : 'light'
);
});
// テーマの適用
state.applyTheme();
}
},
}
)
);
まとめ
Zustand の persist ミドルウェアは、状態の永続化を簡潔に実現する強力な機能です。
主要なメリット:
- 手動での localStorage 操作が不要
- 型安全性を保ったまま実装可能
- 柔軟な設定オプションで様々なユースケースに対応
- パフォーマンスを考慮した自動的な最適化
実装時のポイント:
name
オプションで適切なストレージキーを設定partialize
で必要な状態のみを永続化- カスタムストレージエンジンで要件に応じた保存先を選択
- 型定義を活用してランタイムエラーを防止
注意すべき点:
- 機密情報の保存は避ける
- ブラウザのストレージ容量制限を考慮
- SSR 環境での hydration mismatch に注意
- データの整合性とバージョン管理を適切に行う
persist を活用することで、ユーザーエクスペリエンスを大幅に向上させることができます。設定の記憶、作業状態の復元、オフライン対応など、モダンな Web アプリケーションに求められる機能を効率的に実装していきましょう。