Refactoring State Management with Next.js × Zustand
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))
- Create one bounded store with
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)
- Collect from navigator/window with
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 = trueimmediately if deviceInfo exists, otherwise initialize
- Launch
- Microtask failsafe
- To handle cases where rehydration timing differences prevent firing, run initialization check once in microtask on client side
Concrete Implementation
Store Types
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
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
export const networkConfig = {
ttlMinutes: 60,
maxRetries: 1,
retryDelayMs: 500,
} as const;
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)
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