Refactoring State Management with Next.js × Zustand

by Sloth255
Next.jsReactTypeScriptRefactoringZustand

Refactoring State Management with Next.js × Zustand

Background and Challenges

This app uses Next.js 15 (App Router) + React 19 with static export configuration. The use case involves retrieving, displaying, and sharing network information (IPv4/IPv6, etc.) and device information (UA, etc.). Since it's localized, there are pages for each language.

Challenges before implementation:

  • Retrieval logic scattered across components with duplication (difficult to track side effects)
  • Repeated fetches on every language switch even during the same session (feels slow)
  • Need for consistent access to state from cross-cutting features like share buttons

To solve this, we introduced Zustand and refactored to store-driven state management (initialization, persistence, TTL, retry).

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

Objectives

  • Consolidate side effects on the "data store side" (fetching, saving, refetch decisions)
  • Simplify component rendering by making them "read-only"
  • Speed up perceived performance with session persistence (avoid refetching within same session)
  • Improve reliability with TTL and retry, enable manual updates from UI

Adopted Design

1) Slice Pattern + Bounded Store

  • slices:
    • NetworkSlice: networkInfo, networkLoading, networkError, networkFetchedAt, initializeNetworkInfo
    • DeviceSlice: deviceInfo, deviceReady, initializeDeviceInfo
  • types: Define NetworkInfo/DeviceInfo types and Slice/Store types in types.ts
  • store:
    • Create one bounded store with createStore, composing slices
    • Session persistence with persist(createJSONStorage(() => sessionStorage))

Reading methods:

  • Entire state: const { networkInfo, deviceInfo } = useAppStore();
  • Selector: const info = useAppStore(s => s.networkInfo);

2) Initialization on Store Side

  • Network
    • initializeNetworkInfo(opts?: { force?: boolean })
    • Manage loading/error flags
    • Save networkFetchedAt = Date.now() on success (for TTL determination)
  • Device
    • Collect from navigator/window with initializeDeviceInfo()
    • Manage display readiness with deviceReady (smooth display as before)

3) Auto-start After Rehydration with Safety Measures

  • In onRehydrateStorage, conditionally trigger initialization based on restored state
    • Launch initializeNetworkInfo() if networkInfo is absent
    • Set deviceReady = true immediately if deviceInfo exists, otherwise initialize
  • Microtask failsafe
    • To handle cases where rehydration timing differences prevent firing, run initialization check once in microtask on client side

Concrete Implementation

Store Types

src/store/types.ts
export interface NetworkSlice {
  networkInfo: NetworkInfo | null;
  networkLoading: boolean;
  networkError: string | null;
  networkFetchedAt?: number | null; // For 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 and 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 insurance
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 and Retry

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' });
  }
}

Component Simplification and UI Improvements

  • Removed side effects (useEffect/useLayoutEffect), controlling display with store flags (loading/error/ready)
  • Added "Refresh" button for manual updates (refetch ignoring 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>

Pitfalls Encountered and Solutions

  • "Stuck in loading state"
    • Cause: Flags not updated due to rehydration timing differences
    • Solution: Explicitly set ready in onRehydrateStorage + microtask failsafe
  • Persistence targets
    • Introduced partialize to avoid saving transient flags (loading/error/ready)

Results

  • Perceived performance improvement
    • Instant restoration from persist during session → Skip fetch (within TTL)
    • Eliminated duplicate side effects, reduced unnecessary re-renders
  • Improved reliability
    • Safe initialization immediately after rehydration (onRehydrateStorage + microtask)
    • Retry on failure and manual update from UI
  • Readability and maintainability
    • Consolidated retrieval logic (unified in store)
    • Responsibility separation by slice (network/device)
    • Clear API with Bounded store

Summary

By introducing Zustand with slice pattern + Bounded Store and adopting the role separation of "initialization in store" and "UI reads only," we improved display speed, reliability, and maintainability.
I previously built an app with React × Redux for state management, but Zustand was easier to implement and the code was simpler, leaving a positive impression