Hintergrund und Herausforderungen
Diese App verwendet Next.js 15 (App Router) + React 19 mit Static-Export-Konfiguration. Der Anwendungsfall beinhaltet das Abrufen, Anzeigen und Teilen von Netzwerkinformationen (IPv4/IPv6 usw.) und Geräteinformationen (UA usw.). Da sie lokalisiert ist, gibt es Seiten für jede Sprache.
Herausforderungen vor der Implementierung:
- Abruflogik über Komponenten verteilt und dupliziert (schwer nachzuverfolgende Nebeneffekte)
- Wiederholte Fetches bei jedem Sprachwechsel innerhalb derselben Session (fühlt sich langsam an)
- Notwendigkeit konsistenten Zugriffs auf State von Querschnittsfunktionen wie Share-Buttons
Um dies zu lösen, haben wir Zustand eingeführt und zu storgegesteuertem State-Management refaktoriert (Initialisierung, Persistenz, TTL, Retry).
Zustand: https://zustand-demo.pmnd.rs/
Ziele
- Nebeneffekte auf der „Datenspeicher-Seite" konsolidieren (Fetching, Speichern, Refetch-Entscheidungen)
- Komponenten-Rendering vereinfachen, indem sie „nur-lesend" gemacht werden
- Wahrgenommene Performance mit Session-Persistenz verbessern (Refetching innerhalb derselben Session vermeiden)
- Zuverlässigkeit mit TTL und Retry verbessern, manuelle Updates von der UI ermöglichen
Adoptiertes Design
1) Slice-Pattern + Bounded Store
- Slices:
- NetworkSlice: networkInfo, networkLoading, networkError, networkFetchedAt, initializeNetworkInfo
- DeviceSlice: deviceInfo, deviceReady, initializeDeviceInfo
- Types: NetworkInfo/DeviceInfo-Typen und Slice/Store-Typen in types.ts definieren
- Store:
- Einen Bounded Store mit
createStoreerstellen, der Slices zusammensetzt - Session-Persistenz mit
persist(createJSONStorage(() => sessionStorage))
- Einen Bounded Store mit
Lesemethoden:
- Gesamter State:
const { networkInfo, deviceInfo } = useAppStore(); - Selektor:
const info = useAppStore(s => s.networkInfo);
2) Initialisierung auf Store-Seite
- Netzwerk
initializeNetworkInfo(opts?: { force?: boolean })- Lade-/Fehler-Flags verwalten
networkFetchedAt = Date.now()bei Erfolg speichern (für TTL-Bestimmung)
- Gerät
- Aus navigator/window mit
initializeDeviceInfo()sammeln - Anzeigebereitschaft mit
deviceReadyverwalten (reibungslose Anzeige wie zuvor)
- Aus navigator/window mit
3) Auto-Start nach Rehydrierung mit Sicherheitsmaßnahmen
- In
onRehydrateStorage, Initialisierung basierend auf wiederhergestelltem State bedingt auslöseninitializeNetworkInfo()starten, wenn networkInfo fehltdeviceReady = truesofort setzen, wenn deviceInfo vorhanden, sonst initialisieren
- Microtask-Failsafe
- Um Fälle zu behandeln, bei denen Rehydrierungs-Timing-Unterschiede das Auslösen verhindern, einmal Initialisierungsprüfung im Microtask auf Client-Seite ausführen
Konkrete Implementierung
Store-Typen
export interface NetworkSlice {
networkInfo: NetworkInfo | null;
networkLoading: boolean;
networkError: string | null;
networkFetchedAt?: number | null; // Für 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 und 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-Absicherung
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 und 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' });
}
}
Komponentenvereinfachung und UI-Verbesserungen
- Nebeneffekte entfernt (useEffect/useLayoutEffect), Anzeige durch Store-Flags gesteuert (loading/error/ready)
- „Aktualisieren"-Button für manuelle Updates hinzugefügt (Refetch unter Umgehung von 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>
Aufgetretene Fallstricke und Lösungen
- „Im Ladezustand steckengeblieben"
- Ursache: Flags aufgrund von Rehydrierungs-Timing-Unterschieden nicht aktualisiert
- Lösung: Explizites Setzen des Ready-Zustands in onRehydrateStorage + Microtask-Failsafe
- Persistenz-Ziele
- partialize eingeführt, um das Speichern transienter Flags zu vermeiden (loading/error/ready)
Ergebnisse
- Verbesserte wahrgenommene Performance
- Sofortige Wiederherstellung aus persist während der Session → Fetch überspringen (innerhalb TTL)
- Doppelte Nebeneffekte eliminiert, unnötige Re-Renders reduziert
- Verbesserte Zuverlässigkeit
- Sichere Initialisierung unmittelbar nach Rehydrierung (onRehydrateStorage + Microtask)
- Retry bei Fehlern und manuelle Aktualisierung von der UI
- Lesbarkeit und Wartbarkeit
- Abruflogik konsolidiert (im Store vereinheitlicht)
- Verantwortungstrennung durch Slice (Netzwerk/Gerät)
- Klare API mit Bounded Store
Zusammenfassung
Durch die Einführung von Zustand mit Slice-Pattern + Bounded Store und die Übernahme der Rollentrennung von „Initialisierung im Store" und „UI liest nur", haben wir Anzeigegeschwindigkeit, Zuverlässigkeit und Wartbarkeit verbessert.
Zuvor hatte ich eine App mit React × Redux für das State-Management gebaut, aber Zustand war einfacher zu implementieren und der Code war einfacher, was einen positiven Eindruck hinterließ.
