T-CREATOR

Zustand の状態初期化とリセット処理のパターンまとめ

Zustand の状態初期化とリセット処理のパターンまとめ

Zustand で状態管理を行う際、最も重要な課題の一つが状態の初期化とリセット処理です。適切な状態リセットが実装されていないと、ユーザーが予期しない動作に遭遇したり、アプリケーションの状態が不整合に陥ったりする可能性があります。

特に、ユーザーがログアウトした後に再度ログインした際、前回のセッション情報が残ってしまう問題や、フォームの入力内容が意図せず保持されてしまう問題は、多くの開発者が経験しているのではないでしょうか。

この記事では、Zustand での状態初期化とリセット処理の実践的なパターンを紹介し、アプリケーションの安定性とユーザー体験を向上させる方法を解説いたします。

背景

状態管理における初期化とリセットの必要性

現代の Web アプリケーションでは、複雑な状態管理が求められます。ユーザーの操作履歴、フォームの入力状態、API レスポンスのキャッシュなど、様々な状態がアプリケーション全体で共有されています。

これらの状態は、適切なタイミングで初期化やリセットが行われないと、以下のような問題を引き起こします:

  • セキュリティ上の問題(認証情報の漏洩)
  • ユーザー体験の低下(古いデータの表示)
  • アプリケーションの不整合状態
  • メモリリークの発生

一般的な状態管理ライブラリでの課題

Redux や Context API などの他の状態管理ライブラリでは、状態のリセット処理が複雑になりがちです。特に、以下の点で課題があります:

  • アクションの定義が冗長になる
  • リセット処理の実装が分散しやすい
  • 型安全性の確保が困難
  • パフォーマンスへの影響が大きい

Zustand での状態リセットの特徴

Zustand は、シンプルな API を提供しながらも、柔軟な状態リセット機能を備えています。その特徴は以下の通りです:

  • ストア内での直接的な状態更新
  • TypeScript との優れた統合
  • 軽量で高速な状態管理
  • 直感的な API 設計

課題

状態の永続化による予期しない動作

Zustand で persist middleware を使用している場合、ブラウザのローカルストレージに状態が保存されます。これにより、以下のような問題が発生する可能性があります:

typescript// 問題のある実装例
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UserStore {
  user: User | null;
  isAuthenticated: boolean;
  login: (user: User) => void;
  logout: () => void;
}

const useUserStore = create<UserStore>()(
  persist(
    (set) => ({
      user: null,
      isAuthenticated: false,
      login: (user) => set({ user, isAuthenticated: true }),
      logout: () =>
        set({ user: null, isAuthenticated: false }),
    }),
    {
      name: 'user-storage',
    }
  )
);

この実装では、ログアウト時に状態がリセットされても、ローカルストレージから古い状態が復元されてしまう可能性があります。

複数コンポーネント間での状態共有時のリセット漏れ

複数のコンポーネントが同じストアを参照している場合、一部のコンポーネントでのリセット処理が他のコンポーネントに影響しないことがあります:

typescript// リセット漏れが発生する例
const ComponentA = () => {
  const { resetForm } = useFormStore();

  const handleSubmit = () => {
    // フォーム送信処理
    resetForm(); // このリセットがComponentBに反映されない可能性
  };

  return <button onClick={handleSubmit}>送信</button>;
};

const ComponentB = () => {
  const { formData } = useFormStore();

  // ComponentAでリセットされても、このコンポーネントの状態が
  // 更新されない可能性がある
  return <div>{JSON.stringify(formData)}</div>;
};

ユーザーセッション終了時の状態クリーンアップ

ユーザーがログアウトした際、すべての関連する状態を適切にクリーンアップする必要があります:

typescript// 不完全なクリーンアップの例
const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  token: null,
  logout: () => {
    set({ user: null, token: null });
    // 他のストアの状態がクリーンアップされていない
  },
}));

開発・テスト環境での状態リセットの複雑さ

開発やテスト環境では、状態を簡単にリセットできる仕組みが必要です:

typescript// テスト環境での状態リセットが困難な例
describe('UserStore', () => {
  beforeEach(() => {
    // ストアの状態をリセットする方法がない
  });

  it('should handle login', () => {
    // 前のテストの状態が影響する可能性
  });
});

解決策

パターン 1:初期状態オブジェクトを活用したリセット

最もシンプルで効果的なパターンは、初期状態を定数として定義し、リセット時にその状態に戻す方法です。

初期状態を定数として定義

typescript// 初期状態の定義
const initialState = {
  user: null,
  isAuthenticated: false,
  loading: false,
  error: null,
} as const;

// 型定義
interface UserState {
  user: User | null;
  isAuthenticated: boolean;
  loading: boolean;
  error: string | null;
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
  reset: () => void;
}

リセット関数での初期状態への復元

typescript// ストアの実装
const useUserStore = create<UserState>((set, get) => ({
  ...initialState,

  login: async (credentials) => {
    set({ loading: true, error: null });
    try {
      const user = await loginAPI(credentials);
      set({ user, isAuthenticated: true, loading: false });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : 'ログインに失敗しました',
        loading: false,
      });
    }
  },

  logout: () => {
    set(initialState);
  },

  reset: () => {
    set(initialState);
  },
}));

実装例とコードサンプル

このパターンの利点は、初期状態が明確に定義されており、リセット処理が予測可能であることです:

typescript// 使用例
const UserProfile = () => {
  const { user, logout, reset } = useUserStore();

  const handleLogout = () => {
    logout(); // 初期状態にリセット
  };

  const handleReset = () => {
    reset(); // 明示的なリセット
  };

  return (
    <div>
      <h1>{user?.name}</h1>
      <button onClick={handleLogout}>ログアウト</button>
      <button onClick={handleReset}>リセット</button>
    </div>
  );
};

パターン 2:reset 関数を明示的に定義

より細かい制御が必要な場合は、明示的な reset 関数を定義します。

ストア内での reset 関数の実装

typescriptinterface FormStore {
  formData: FormData;
  errors: Record<string, string>;
  isSubmitting: boolean;
  reset: () => void;
  resetErrors: () => void;
  resetSubmitting: () => void;
}

const useFormStore = create<FormStore>((set) => ({
  formData: {},
  errors: {},
  isSubmitting: false,

  reset: () => {
    set({
      formData: {},
      errors: {},
      isSubmitting: false,
    });
  },

  resetErrors: () => {
    set({ errors: {} });
  },

  resetSubmitting: () => {
    set({ isSubmitting: false });
  },
}));

部分的な状態リセットの実現

typescript// 部分的なリセットの実装
interface CartStore {
  items: CartItem[];
  total: number;
  discount: number;
  resetAll: () => void;
  resetItems: () => void;
  resetDiscount: () => void;
}

const useCartStore = create<CartStore>((set) => ({
  items: [],
  total: 0,
  discount: 0,

  resetAll: () => {
    set({ items: [], total: 0, discount: 0 });
  },

  resetItems: () => {
    set({ items: [], total: 0 });
  },

  resetDiscount: () => {
    set({ discount: 0 });
  },
}));

複数ストア間での連携リセット

typescript// 複数ストアの連携リセット
const resetAllStores = () => {
  useUserStore.getState().reset();
  useFormStore.getState().reset();
  useCartStore.getState().resetAll();
};

// または、カスタムフックとして実装
const useGlobalReset = () => {
  const resetUser = useUserStore((state) => state.reset);
  const resetForm = useFormStore((state) => state.reset);
  const resetCart = useCartStore((state) => state.resetAll);

  return () => {
    resetUser();
    resetForm();
    resetCart();
  };
};

パターン 3:Middleware を活用した自動リセット

Zustand の middleware 機能を活用して、自動的なリセット処理を実装できます。

persist middleware との組み合わせ

typescript// persist middlewareと組み合わせたリセット
const useUserStore = create<UserState>()(
  persist(
    (set, get) => ({
      ...initialState,

      login: async (credentials) => {
        // ログイン処理
        set({ user, isAuthenticated: true });
      },

      logout: () => {
        // ローカルストレージも含めて完全リセット
        set(initialState);
        // persist middlewareのストレージをクリア
        localStorage.removeItem('user-storage');
      },
    }),
    {
      name: 'user-storage',
      // 特定の条件でのみ永続化
      partialize: (state) => ({
        user: state.user,
        isAuthenticated: state.isAuthenticated,
      }),
    }
  )
);

カスタム middleware でのリセット処理

typescript// カスタムリセットmiddleware
const resetMiddleware = (config) => (set, get, api) => {
  const initialState = config.initialState

  return config(
    (...args) => {
      set(...args)
    },
    get,
    {
      ...api,
      reset: () => set(initialState),
      resetTo: (newState) => set(newState)
    }
  )
}

// 使用例
const useCounterStore = create(
  resetMiddleware({
    initialState: { count: 0 },
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 }))
    })
  })
)

条件付きリセットの実装

typescript// 条件付きリセットmiddleware
const conditionalResetMiddleware = (config) => (set, get, api) => {
  const { shouldReset, resetCondition } = config

  return config(
    (...args) => {
      set(...args)

      // 条件を満たした場合に自動リセット
      if (shouldReset && resetCondition(get())) {
        set(config.initialState)
      }
    },
    get,
    api
  )
}

// 使用例
const useSessionStore = create(
  conditionalResetMiddleware({
    initialState: { sessionId: null, lastActivity: null },
    shouldReset: true,
    resetCondition: (state) => {
      // 30分以上アクティビティがない場合にリセット
      return state.lastActivity &&
             Date.now() - state.lastActivity > 30 * 60 * 1000
    },
    (set) => ({
      sessionId: null,
      lastActivity: null,
      updateActivity: () => set({ lastActivity: Date.now() })
    })
  })
)

パターン 4:React Hooks との連携

React Hooks と組み合わせることで、より柔軟なリセット処理を実現できます。

useEffect でのクリーンアップ処理

typescript// コンポーネントアンマウント時の自動リセット
const UserProfile = () => {
  const { user, reset } = useUserStore();

  useEffect(() => {
    // コンポーネントマウント時の処理

    return () => {
      // アンマウント時にリセット
      reset();
    };
  }, [reset]);

  return <div>{user?.name}</div>;
};

コンポーネントアンマウント時の自動リセット

typescript// カスタムフックでの自動リセット
const useAutoReset = (store, resetFunction) => {
  useEffect(() => {
    return () => {
      resetFunction();
    };
  }, [resetFunction]);
};

// 使用例
const FormComponent = () => {
  const { formData, reset } = useFormStore();

  // コンポーネントアンマウント時に自動リセット
  useAutoReset(useFormStore, reset);

  return <form>{/* フォーム内容 */}</form>;
};

カスタムフックでのリセット機能

typescript// リセット機能付きカスタムフック
const useResetableStore = (store, initialState) => {
  const [state, actions] = store();

  const reset = useCallback(() => {
    actions.reset(initialState);
  }, [actions, initialState]);

  const resetTo = useCallback(
    (newState) => {
      actions.resetTo(newState);
    },
    [actions]
  );

  return [state, { ...actions, reset, resetTo }];
};

// 使用例
const useUserStoreWithReset = () => {
  return useResetableStore(useUserStore, initialState);
};

パターン 5:TypeScript 型システムを活用

TypeScript の型システムを活用することで、型安全なリセット処理を実装できます。

型安全なリセット処理

typescript// 型安全なリセット関数の定義
type ResetFunction<T> = () => T;
type PartialResetFunction<T, K extends keyof T> = (
  keys: K[]
) => Pick<T, K>;

interface TypedStore<T> {
  state: T;
  reset: ResetFunction<T>;
  resetPartial: PartialResetFunction<T, keyof T>;
}

// 型安全なストアの実装
const createTypedStore = <T extends Record<string, any>>(
  initialState: T
): TypedStore<T> => {
  const store = create<
    T & {
      reset: () => void;
      resetPartial: (keys: (keyof T)[]) => void;
    }
  >((set, get) => ({
    ...initialState,

    reset: () => {
      set(initialState);
    },

    resetPartial: (keys) => {
      const currentState = get();
      const newState = { ...currentState };

      keys.forEach((key) => {
        newState[key] = initialState[key];
      });

      set(newState);
    },
  }));

  return {
    state: store.getState(),
    reset: store.getState().reset,
    resetPartial: store.getState().resetPartial,
  };
};

部分的な状態リセットの型定義

typescript// 部分的なリセットの型定義
type ResetableKeys<T> = {
  [K in keyof T]: T[K] extends object ? never : K;
}[keyof T];

interface PartialResetStore<T> {
  state: T;
  reset: () => void;
  resetField: <K extends ResetableKeys<T>>(
    field: K
  ) => void;
  resetFields: <K extends ResetableKeys<T>>(
    fields: K[]
  ) => void;
}

// 実装例
const useTypedFormStore = create<
  FormState & PartialResetStore<FormState>
>((set, get) => ({
  name: '',
  email: '',
  age: 0,

  reset: () => {
    set({ name: '', email: '', age: 0 });
  },

  resetField: (field) => {
    const initialState = { name: '', email: '', age: 0 };
    set({
      [field]: initialState[field],
    } as Partial<FormState>);
  },

  resetFields: (fields) => {
    const initialState = { name: '', email: '', age: 0 };
    const resetData = fields.reduce((acc, field) => {
      acc[field] = initialState[field];
      return acc;
    }, {} as Partial<FormState>);

    set(resetData);
  },
}));

リセット関数の型制約

typescript// リセット関数の型制約
type ResetConstraint<T> = {
  [K in keyof T]: T[K] extends Function ? never : T[K];
};

interface ConstrainedStore<T> {
  state: ResetConstraint<T>;
  reset: () => ResetConstraint<T>;
  resetWithValidation: (
    newState: Partial<ResetConstraint<T>>
  ) => void;
}

// バリデーション付きリセット
const createValidatedStore = <
  T extends Record<string, any>
>(
  initialState: T,
  validator: (state: T) => boolean
) => {
  return create<T & ConstrainedStore<T>>((set, get) => ({
    ...initialState,

    reset: () => {
      if (validator(initialState)) {
        set(initialState);
      } else {
        throw new Error('Invalid initial state');
      }
    },

    resetWithValidation: (newState) => {
      const validatedState = { ...get(), ...newState };
      if (validator(validatedState)) {
        set(validatedState);
      } else {
        throw new Error('Invalid state for reset');
      }
    },
  }));
};

具体例

ユーザー認証ストアのリセット実装

実際のユーザー認証システムでのリセット処理を実装してみましょう:

typescript// ユーザー認証ストアの完全な実装
interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  loading: boolean;
  error: string | null;
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
  reset: () => void;
  clearError: () => void;
}

const initialState: Omit<
  AuthState,
  'login' | 'logout' | 'reset' | 'clearError'
> = {
  user: null,
  token: null,
  isAuthenticated: false,
  loading: false,
  error: null,
};

const useAuthStore = create<AuthState>((set, get) => ({
  ...initialState,

  login: async (credentials) => {
    set({ loading: true, error: null });
    try {
      const response = await loginAPI(credentials);
      set({
        user: response.user,
        token: response.token,
        isAuthenticated: true,
        loading: false,
      });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : 'ログインに失敗しました',
        loading: false,
      });
    }
  },

  logout: () => {
    // トークンの無効化処理
    const { token } = get();
    if (token) {
      invalidateTokenAPI(token);
    }

    // 状態の完全リセット
    set(initialState);

    // ローカルストレージのクリア
    localStorage.removeItem('auth-storage');
    sessionStorage.clear();
  },

  reset: () => {
    set(initialState);
  },

  clearError: () => {
    set({ error: null });
  },
}));

フォーム状態のリセット処理

複雑なフォーム状態のリセット処理を実装します:

typescript// フォーム状態の管理
interface FormState {
  data: Record<string, any>;
  errors: Record<string, string>;
  touched: Record<string, boolean>;
  isSubmitting: boolean;
  isDirty: boolean;
  setField: (field: string, value: any) => void;
  setError: (field: string, error: string) => void;
  setTouched: (field: string, touched: boolean) => void;
  reset: () => void;
  resetErrors: () => void;
  resetTouched: () => void;
  resetField: (field: string) => void;
}

const useFormStore = create<FormState>((set, get) => ({
  data: {},
  errors: {},
  touched: {},
  isSubmitting: false,
  isDirty: false,

  setField: (field, value) => {
    set((state) => ({
      data: { ...state.data, [field]: value },
      isDirty: true,
    }));
  },

  setError: (field, error) => {
    set((state) => ({
      errors: { ...state.errors, [field]: error },
    }));
  },

  setTouched: (field, touched) => {
    set((state) => ({
      touched: { ...state.touched, [field]: touched },
    }));
  },

  reset: () => {
    set({
      data: {},
      errors: {},
      touched: {},
      isSubmitting: false,
      isDirty: false,
    });
  },

  resetErrors: () => {
    set({ errors: {} });
  },

  resetTouched: () => {
    set({ touched: {} });
  },

  resetField: (field) => {
    set((state) => {
      const newData = { ...state.data };
      const newErrors = { ...state.errors };
      const newTouched = { ...state.touched };

      delete newData[field];
      delete newErrors[field];
      delete newTouched[field];

      return {
        data: newData,
        errors: newErrors,
        touched: newTouched,
      };
    });
  },
}));

ページ遷移時の状態クリーンアップ

Next.js でのページ遷移時の状態クリーンアップを実装します:

typescript// ページ遷移時の状態管理
interface PageState {
  currentPage: string;
  pageData: Record<string, any>;
  isLoading: boolean;
  setPageData: (page: string, data: any) => void;
  clearPageData: (page?: string) => void;
  resetAllPages: () => void;
}

const usePageStore = create<PageState>((set, get) => ({
  currentPage: '',
  pageData: {},
  isLoading: false,

  setPageData: (page, data) => {
    set((state) => ({
      pageData: { ...state.pageData, [page]: data },
    }));
  },

  clearPageData: (page) => {
    if (page) {
      set((state) => {
        const newPageData = { ...state.pageData };
        delete newPageData[page];
        return { pageData: newPageData };
      });
    } else {
      set({ pageData: {} });
    }
  },

  resetAllPages: () => {
    set({
      currentPage: '',
      pageData: {},
      isLoading: false,
    });
  },
}));

// Next.jsでの使用例
const usePageTransition = () => {
  const { clearPageData, resetAllPages } = usePageStore();

  useEffect(() => {
    const handleRouteChange = (url: string) => {
      // 特定のページ遷移時にデータをクリア
      if (url.includes('/admin')) {
        clearPageData('user-dashboard');
      }
    };

    const handleRouteChangeComplete = () => {
      // ページ遷移完了時の処理
    };

    router.events.on('routeChangeStart', handleRouteChange);
    router.events.on(
      'routeChangeComplete',
      handleRouteChangeComplete
    );

    return () => {
      router.events.off(
        'routeChangeStart',
        handleRouteChange
      );
      router.events.off(
        'routeChangeComplete',
        handleRouteChangeComplete
      );
    };
  }, [clearPageData]);

  return { resetAllPages };
};

エラー状態のリセットパターン

エラー状態の適切なリセット処理を実装します:

typescript// エラー状態の管理
interface ErrorState {
  errors: Record<string, ErrorInfo>;
  globalError: string | null;
  addError: (key: string, error: ErrorInfo) => void;
  removeError: (key: string) => void;
  clearErrors: () => void;
  setGlobalError: (error: string | null) => void;
  resetAllErrors: () => void;
}

interface ErrorInfo {
  message: string;
  code: string;
  timestamp: number;
  retryable: boolean;
}

const useErrorStore = create<ErrorState>((set, get) => ({
  errors: {},
  globalError: null,

  addError: (key, error) => {
    set((state) => ({
      errors: {
        ...state.errors,
        [key]: { ...error, timestamp: Date.now() },
      },
    }));
  },

  removeError: (key) => {
    set((state) => {
      const newErrors = { ...state.errors };
      delete newErrors[key];
      return { errors: newErrors };
    });
  },

  clearErrors: () => {
    set({ errors: {} });
  },

  setGlobalError: (error) => {
    set({ globalError: error });
  },

  resetAllErrors: () => {
    set({ errors: {}, globalError: null });
  },
}));

// エラーハンドリングコンポーネント
const ErrorBoundary = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const { globalError, resetAllErrors } = useErrorStore();

  useEffect(() => {
    // 一定時間後にエラーを自動クリア
    if (globalError) {
      const timer = setTimeout(() => {
        resetAllErrors();
      }, 5000);

      return () => clearTimeout(timer);
    }
  }, [globalError, resetAllErrors]);

  if (globalError) {
    return (
      <div className='error-boundary'>
        <p>{globalError}</p>
        <button onClick={resetAllErrors}>
          エラーをクリア
        </button>
      </div>
    );
  }

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

まとめ

各パターンの使い分けと選択基準

今回紹介した 5 つのパターンは、それぞれ異なるユースケースに適しています:

  1. 初期状態オブジェクトを活用したリセット

    • シンプルな状態管理に最適
    • 予測可能なリセット処理が必要な場合
    • 小規模から中規模のアプリケーション
  2. reset 関数を明示的に定義

    • 部分的なリセットが必要な場合
    • 複雑な状態構造を持つアプリケーション
    • 細かい制御が必要な場合
  3. Middleware を活用した自動リセット

    • 永続化が必要な状態管理
    • 条件付きの自動リセットが必要な場合
    • 高度な状態管理機能が必要な場合
  4. React Hooks との連携

    • コンポーネントライフサイクルと連携が必要な場合
    • 自動的なクリーンアップが必要な場合
    • カスタムフックでの再利用性を重視する場合
  5. TypeScript 型システムを活用

    • 型安全性を重視する場合
    • 大規模なアプリケーション開発
    • チーム開発での保守性を重視する場合

実装時の注意点とベストプラクティス

  1. 初期状態の一貫性

    • 初期状態は必ず定数として定義する
    • 型定義と初期状態の整合性を保つ
    • 初期状態の変更時は影響範囲を確認する
  2. リセット処理のタイミング

    • ユーザーアクションに応じた適切なタイミングでリセット
    • コンポーネントのライフサイクルを考慮
    • パフォーマンスへの影響を最小限に抑える
  3. エラーハンドリング

    • リセット処理中のエラーを適切に処理
    • 部分的なリセット失敗時のフォールバック
    • ユーザーへの適切なフィードバック
  4. テストの実装

    • リセット処理の単体テスト
    • 統合テストでの状態リセットの確認
    • エラーケースのテスト

パフォーマンスと保守性のバランス

  1. メモリ使用量の最適化

    • 不要な状態の保持を避ける
    • 適切なタイミングでのガベージコレクション
    • 大きなオブジェクトの参照を避ける
  2. 再レンダリングの最適化

    • 必要な部分のみをリセット
    • 不要な再レンダリングを防ぐ
    • selector の適切な使用
  3. コードの保守性

    • リセット処理の一貫性を保つ
    • ドキュメントの整備
    • 命名規則の統一

Zustand での状態初期化とリセット処理は、アプリケーションの安定性とユーザー体験に直接影響します。適切なパターンを選択し、実装することで、予測可能で保守しやすい状態管理システムを構築できます。

特に、ユーザーのセキュリティとプライバシーを保護するため、認証情報や個人情報を含む状態の適切なリセットは重要です。また、開発効率を向上させるため、テスト環境での状態リセット機能も忘れずに実装しましょう。

関連リンク