Next.jsReactTypeScriptRefactoringZustand

Refactoring de la gestion d'état avec Next.js × Zustand

Sloth255
Sloth255
·3 min read·660 words

Contexte et défis

Cette application utilise Next.js 15 (App Router) + React 19 avec configuration d'export statique. Le cas d'utilisation implique la récupération, l'affichage et le partage d'informations réseau (IPv4/IPv6, etc.) et d'informations sur l'appareil (UA, etc.). Étant localisée, il existe des pages pour chaque langue.

Défis avant l'implémentation :

  • Logique de récupération dispersée dans les composants avec duplication (difficile à suivre les effets secondaires)
  • Fetches répétés à chaque changement de langue même pendant la même session (lent)
  • Besoin d'accès cohérent à l'état depuis les fonctionnalités transversales comme les boutons de partage

Pour résoudre ces problèmes, nous avons introduit Zustand et refactorisé vers une gestion d'état pilotée par le store (initialisation, persistance, TTL, retry).

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

Objectifs

  • Consolider les effets secondaires côté « data store » (fetch, sauvegarde, décisions de refetch)
  • Simplifier le rendu des composants en les rendant « en lecture seule »
  • Améliorer la performance perçue avec la persistance de session (éviter les refetch dans la même session)
  • Améliorer la fiabilité avec TTL et retry, activer les mises à jour manuelles depuis l'UI

Architecture adoptée

1) Pattern Slice + Bounded Store

  • slices :
    • NetworkSlice : networkInfo, networkLoading, networkError, networkFetchedAt, initializeNetworkInfo
    • DeviceSlice : deviceInfo, deviceReady, initializeDeviceInfo
  • types : Définir les types NetworkInfo/DeviceInfo et les types Slice/Store dans types.ts
  • store :
    • Créer un bounded store avec createStore, composant les slices
    • Persistance de session avec persist(createJSONStorage(() => sessionStorage))

Méthodes de lecture :

  • État entier : const { networkInfo, deviceInfo } = useAppStore();
  • Sélecteur : const info = useAppStore(s => s.networkInfo);

2) Initialisation côté store

  • Réseau
    • initializeNetworkInfo(opts?: { force?: boolean })
    • Gérer les flags loading/error
    • Sauvegarder networkFetchedAt = Date.now() en cas de succès (pour la détermination TTL)
  • Appareil
    • Collecter depuis navigator/window avec initializeDeviceInfo()
    • Gérer la disponibilité d'affichage avec deviceReady (affichage fluide comme avant)

3) Auto-démarrage après réhydratation avec mesures de sécurité

  • Dans onRehydrateStorage, déclencher conditionnellement l'initialisation selon l'état restauré
    • Lancer initializeNetworkInfo() si networkInfo est absent
    • Définir deviceReady = true immédiatement si deviceInfo existe, sinon initialiser
  • Protection microtask
    • Pour gérer les cas où les différences de timing de réhydratation empêchent le déclenchement, exécuter une vérification d'initialisation une fois dans une microtask côté client

Implémentation concrète

Types du store

src/store/types.ts
export interface NetworkSlice {
  networkInfo: NetworkInfo | null;
  networkLoading: boolean;
  networkError: string | null;
  networkFetchedAt?: number | null; // Pour 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 et 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();
      },
    }
  )
);

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

Simplification des composants et améliorations UI

  • Suppression des effets secondaires (useEffect/useLayoutEffect), contrôle de l'affichage avec les flags du store (loading/error/ready)
  • Ajout d'un bouton « Actualiser » pour les mises à jour manuelles (refetch ignorant le 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>

Pièges rencontrés et solutions

  • « Bloqué en état de chargement »
    • Cause : Flags non mis à jour en raison de différences de timing de réhydratation
    • Solution : Définir explicitement ready dans onRehydrateStorage + protection microtask
  • Cibles de persistance
    • Introduction de partialize pour éviter de sauvegarder les flags transitoires (loading/error/ready)

Résultats

  • Amélioration de la performance perçue
    • Restauration instantanée depuis persist pendant la session → Skip du fetch (dans le TTL)
    • Élimination des effets secondaires dupliqués, réduction des re-renders inutiles
  • Fiabilité améliorée
    • Initialisation sûre immédiatement après la réhydratation (onRehydrateStorage + microtask)
    • Retry en cas d'échec et mise à jour manuelle depuis l'UI
  • Lisibilité et maintenabilité
    • Logique de récupération consolidée (unifiée dans le store)
    • Séparation des responsabilités par slice (network/device)
    • API claire avec Bounded store

Résumé

En introduisant Zustand avec pattern slice + Bounded Store et en adoptant la séparation des rôles « initialisation dans le store » et « UI en lecture seule », nous avons amélioré la vitesse d'affichage, la fiabilité et la maintenabilité.
J'avais précédemment construit une application avec React × Redux pour la gestion d'état, mais Zustand était plus facile à implémenter et le code était plus simple, laissant une impression positive.