💫

Next.js × Zustand でステート管理をリファクタリングする

に公開

背景と課題

このアプリは Next.js 15(App Router)+ React 19 の静的エクスポート構成。ネットワーク情報(IPv4/IPv6等)とデバイス情報(UA等)を取得・表示・共有するユースケースです。ローカライズしているため、各言語のページがあります。

導入前に抱えていた課題:

  • 取得ロジックが各コンポーネントに散在して重複(副作用の把握が難しい)
  • セッション中の再訪でも言語切り替えなどのたびに毎回取得が走る(体感遅い)
  • 共有ボタンなど横断機能から、状態への一貫したアクセスが必要

これを解決するため、Zustand を導入し、store 主導でのステート管理(初期化・永続化・TTL・リトライ)をリファクタリングしました。

Zustand : https://zustand-demo.pmnd.rs/

ねらい

  • 副作用は「データストア側」に寄せる(取得、保存、再取得の判断)
  • コンポーネントは「読むだけ」にしてレンダリングをシンプル化
  • セッション永続化で体感を速く(同セッション内で再取得を避ける)
  • TTL とリトライで信頼性アップ、UI から手動更新も可能に

採用した設計

1) スライスパターン + Bounded Store

  • slices:
    • NetworkSlice: networkInfo, networkLoading, networkError, networkFetchedAt, initializeNetworkInfo
    • DeviceSlice: deviceInfo, deviceReady, initializeDeviceInfo
  • types: types.ts に NetworkInfo/DeviceInfo の型と Slice/Store 型を定義
  • store:
    • createStore で 1 つの bounded store を作成し、slice を合成
    • persist(createJSONStorage(() => sessionStorage)) でセッション永続化

読み取り方法:

  • 全体: const { networkInfo, deviceInfo } = useAppStore();
  • セレクタ: const info = useAppStore(s => s.networkInfo);

2) 初期化はストア側で

  • Network
    • initializeNetworkInfo(opts?: { force?: boolean })
    • ロード中/エラーのフラグ管理
    • 成功時 networkFetchedAt = Date.now() を保存(TTL 判定用)
  • Device
    • initializeDeviceInfo() で navigator/window から収集
    • 表示準備を deviceReady で管理(前と同じスムース表示)

3) リハイドレート後の自動起動と安全策

  • onRehydrateStorage で、復元済み state をみて初期化を条件起動
    • networkInfo が無ければ initializeNetworkInfo() 起動
    • deviceInfo があれば deviceReady = true を即座にセット、なければ初期化
  • microtask のフェイルセーフ
    • リハイドレートのタイミング差で発火しないケースに備えて、クライアント側で microtask でも初期化チェックを一度走らせる

具体的な実装

ストア型

src/store/types.ts
export interface NetworkSlice {
  networkInfo: NetworkInfo | null;
  networkLoading: boolean;
  networkError: string | null;
  networkFetchedAt?: number | null; // TTL 用
  setNetworkInfo: (info: NetworkInfo | null) => void;
  initializeNetworkInfo: (opts?: { force?: boolean }) => Promise<void>;
}

export interface DeviceSlice {
  deviceInfo: DeviceInfo | null;
  deviceReady: boolean;
  setDeviceInfo: (info: DeviceInfo | null) => void;
  initializeDeviceInfo: () => void;
}

Bounded Store と persist

src/store/useAppStore.ts
export const appStore = createStore<AppStore>()(
  persist(
    (set, get, api) => ({
      ...createNetworkSlice(set, get, api),
      ...createDeviceSlice(set, get, api),
    }),
    {
      name: 'own-info-app-storage',
      storage: createJSONStorage(() => sessionStorage),
      partialize: (state) => ({
        networkInfo: state.networkInfo,
        networkFetchedAt: state.networkFetchedAt ?? null,
        deviceInfo: state.deviceInfo,
      }),
      onRehydrateStorage: () => (rehydrated) => {
        if (typeof window === 'undefined') return;
        const s = appStore.getState();
        if (!rehydrated?.networkInfo) s.initializeNetworkInfo().catch(() => {});
        if (rehydrated?.deviceInfo) appStore.setState({ deviceReady: true });
        else s.initializeDeviceInfo();
      },
    }
  )
);

// microtask の保険
if (typeof window !== 'undefined') {
  queueMicrotask(() => {
    const s = appStore.getState();
    if (!s.networkInfo && !s.networkLoading) s.initializeNetworkInfo().catch(() => {});
    if (s.deviceInfo && !s.deviceReady) appStore.setState({ deviceReady: true });
    else if (!s.deviceInfo && !s.deviceReady) s.initializeDeviceInfo();
  });
}

TTL とリトライ

src/app/config.ts
export const networkConfig = {
  ttlMinutes: 60,
  maxRetries: 1,
  retryDelayMs: 500,
} as const;
src/store/networkSlice.ts
import { networkConfig } from '@/app/config';
const DEFAULT_TTL_MS = Math.max(0, networkConfig.ttlMinutes) * 60 * 1000;
const MAX_RETRIES = Math.max(0, networkConfig.maxRetries);
const RETRY_DELAY_MS = Math.max(0, networkConfig.retryDelayMs);

initializeNetworkInfo: async ({ force } = {}) => {
  const { networkInfo, networkLoading, networkFetchedAt } = get();
  if (networkLoading) return;
  const fresh = networkInfo && networkFetchedAt && Date.now() - networkFetchedAt < DEFAULT_TTL_MS;
  if (!force && fresh) return;

  set({ networkLoading: true, networkError: null });
  try {
    const attemptFetch = async () => {
      const token = process.env.TOKEN;
      const [v4Res, v6Res] = await Promise.all([
        fetch(`https://<api>/v4?token=${token}`),
        fetch(`https://<api>/v6?token=${token}`),
      ]);
      const v4Json = v4Res.ok ? await v4Res.json() : '';
      const v6Json = v6Res.ok ? await v6Res.json() : '';
      return {
        ipv4: v4Json?.ip,
        ipv6: v6Json?.ip
      };
    };

    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      try {
        const data = await attemptFetch();
        set({ networkInfo: data, networkFetchedAt: Date.now(), networkLoading: false, networkError: null });
        return;
      } catch {
        if (attempt < MAX_RETRIES) {
          await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
          continue;
        }
      }
    }
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  } catch {
    set({ networkLoading: false, networkError: 'network_fetch_failed' });
  }
}

コンポーネント側のシンプル化と UI 改善

  • 副作用(useEffect/useLayoutEffect)を撤去し、store のフラグ(loading/error/ready)で表示制御
  • 「更新」ボタンを追加して手動更新に対応(TTL を無視して再取得)
src/components/NetworkInfoClient.tsx
const { networkInfo: info, networkLoading, networkError, initializeNetworkInfo } = useAppStore();

<button
  type="button"
  onClick={() => initializeNetworkInfo({ force: true })}
  disabled={networkLoading}
  className={...}
  title={networkLoading ? L('network.refreshing','Refreshing...') : L('network.refresh','Refresh')}
>
  {networkLoading ? <Spinner/> : <span>{L('network.refresh','Refresh')}</span>}
</button>

直面した落とし穴と対処

  • 「ロード中のまま切り替わらない」
    • 原因: リハイドレートタイミングの差でフラグが更新されないことがある
    • 対処: onRehydrateStorage で明示的に ready をセット + microtask フェイルセーフ
  • 永続化の対象
    • transient なフラグ(loading/error/ready)を保存しないよう partialize を導入

成果

  • 体感の高速化
    • セッション中は persist から即復元 → 取得スキップ(TTL 内)
    • 副作用の重複がなくなり、余計な再レンダリングも減少
  • 信頼性向上
    • リハイドレート直後の安全な初期化(onRehydrateStorage + microtask)
    • 失敗時のリトライと UI 側の手動更新
  • 可読性・保守性
    • 取得ロジックの集約(store に一本化)
    • slice 単位の責務分割(network/device)
    • Bounded store で API が明確

まとめ

Zustand をスライスパターン + Bounded Store で導入し、「初期化はストア」「UI は読むだけ」という役割分担にしたことで、表示速度・信頼性・保守性を底上げできました。
以前、React × Reduxでステート管理するアプリを作成したことがありますが、Zustandは導入が楽でコードもシンプルで好印象でした。

[PR]つくったアプリはこちら📱

https://own-info-app.sloth255.com

Discussion