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))
- Créer un bounded store avec
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)
- Collecter depuis navigator/window avec
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 = trueimmédiatement si deviceInfo existe, sinon initialiser
- Lancer
- 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
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
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
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' });
}
}
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)
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.
