Next.jsReactTypeScriptRefactoringZustand

State-Management mit Next.js × Zustand refaktorieren

Sloth255
Sloth255
·3 min read·499 words

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 createStore erstellen, der Slices zusammensetzt
    • Session-Persistenz mit persist(createJSONStorage(() => sessionStorage))

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 deviceReady verwalten (reibungslose Anzeige wie zuvor)

3) Auto-Start nach Rehydrierung mit Sicherheitsmaßnahmen

  • In onRehydrateStorage, Initialisierung basierend auf wiederhergestelltem State bedingt auslösen
    • initializeNetworkInfo() starten, wenn networkInfo fehlt
    • deviceReady = true sofort 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

src/store/types.ts
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

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-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

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

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)
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>

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ß.