12k
All articles

Comment persister l'état dans le stockage local avec React

Conservez l’état React dans localStorage avec un hook réutilisable : useState lazy, JSON try/catch, garde SSR et synchronisation entre onglets.

OpenReplay Team
OpenReplay Team
Comment persister l'état dans le stockage local avec React

Pour persister l’état React dans localStorage, initialisez useState depuis le stockage dans sa fonction d’initialisation et réécrivez la valeur à chaque modification — puis encapsulez le JSON dans un bloc try/catch et protégez-vous contre le rendu côté serveur.

Toute application React finit par développer ce mécanisme, généralement pour un bouton de basculement de thème ou une barre latérale qui doit rester réduite. La version en trois lignes s’écrit en cinq minutes, puis vous coûte silencieusement un après-midi plus tard. Cette version naïve fonctionne pour un compteur sur un seul onglet, mais elle échoue de trois façons prévisibles : elle plante sur des données corrompues, lève une exception window is not defined dans Next.js, et devient obsolète entre les onglets. Cet article construit un hook useLocalStorage en gravissant les échelons de la robustesse, en corrigeant chaque mode de défaillance l’un après l’autre, et se conclut par un hook prêt à l’emploi que vous pouvez coller dans un projet React 18 ou 19.

localStorage est un magasin clé/valeur synchrone, limité à la même origine, ne stockant que des chaînes de caractères, d’environ 5 Mo par origine, documenté dans l’API Web Storage de MDN. Une règle avant tout code : ne jamais y stocker de jetons d’authentification ni de données personnelles. Il est lisible par tout JavaScript présent sur la page et n’est pas chiffré.

Points clés à retenir

  • Lisez localStorage dans l’initialiseur de useState afin que la lecture n’ait lieu qu’une seule fois au montage, plutôt que d’afficher d’abord la valeur par défaut via un useEffect.
  • Comme localStorage ne stocke que des chaînes, persistez avec JSON.stringify à l’écriture et JSON.parse à la lecture, encapsulés dans un bloc try/catch pour qu’une valeur corrompue ne fasse pas planter le composant.
  • Côté serveur, il n’y a pas de window, donc lire le stockage lors du premier rendu lève une exception window is not defined dans Next.js et Remix. Affichez la valeur par défaut côté serveur et synchronisez avec la valeur persistée après le montage.
  • L’événement storage du navigateur ne se déclenche que dans les autres onglets, jamais dans celui qui a écrit la valeur ; les écouteurs du même onglet nécessitent donc un événement déclenché manuellement.
  • useSyncExternalStore, ajouté dans React 18, est la méthode officiellement recommandée pour abonner un composant à un magasin mutable externe tel que localStorage.

Le pattern naïf React localStorage

Le point de départ est un useState avec initialisation paresseuse associé à un effet d’écriture. Dans React, lisez localStorage dans la fonction d’initialisation de useState afin que la lecture n’ait lieu qu’une seule fois au montage, plutôt que de le lire dans un useEffect qui afficherait d’abord la valeur par défaut.

import { useState, useEffect } from 'react';

function ThemeToggle() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);

  return (
    <button onClick={() => setTheme(t => (t === 'light' ? 'dark' : 'light'))}>
      Theme: {theme}
    </button>
  );
}

Passer une fonction à useState (et non useState(localStorage.getItem(...))) est important : l’initialiseur paresseux ne s’exécute qu’au premier rendu, ce qui évite d’accéder à localStorage à chaque nouveau rendu. Lire dans l’initialiseur plutôt que dans un useEffect séparé garantit également que la valeur correcte est présente dès le premier affichage, sans effet de flash entre la valeur par défaut et la valeur persistée.

Sérialiser en toute sécurité avec JSON et try/catch

La version naïve ne gère que les chaînes de caractères. Comme localStorage ne stocke que des chaînes, persistez les états non-chaînes avec JSON.stringify à l’écriture et JSON.parse à la lecture, et encapsulez le parsing dans un bloc try/catch pour qu’une valeur corrompue ou héritée ne fasse pas planter le composant. Un mode de défaillance courant en production est un changement de schéma ou une valeur partiellement écrite laissant du JSON invalide sous une clé ; sans cette protection, JSON.parse lève une exception au montage et fait tomber le composant.

function readJSON<T>(key: string, fallback: T): T {
  try {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : fallback;
  } catch {
    return fallback; // valeur corrompue ou héritée → retour à la valeur par défaut
  }
}

La branche catch retourne la valeur par défaut au lieu de propager l’erreur, ce qui fait toute la différence entre une clé défectueuse qui réinitialise une préférence et une clé défectueuse qui fait planter la page.

Comment construire un hook useLocalStorage réutilisable ?

Encapsulez le pattern dans un hook qui reproduit l’interface de useState pour en faire un remplacement direct. Pour conserver la parité avec useState, le setter de votre useLocalStorage doit accepter une mise à jour fonctionnelle, de sorte que setValue(prev => prev + 1) fonctionne de la même façon qu’avec l’état natif. C’est le manque ergonomique que la plupart des implémentations artisanales négligent.

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => readJSON(key, initialValue));

  const set = useCallback(
    (next: T | ((prev: T) => T)) => {
      setValue(prev => {
        const resolved = next instanceof Function ? next(prev) : next;
        localStorage.setItem(key, JSON.stringify(resolved));
        return resolved;
      });
    },
    [key],
  );

  return [value, set] as const;
}

La vérification next instanceof Function est ce qui préserve l’ergonomie de useState. Cette version est correcte côté client, mais elle lit toujours localStorage lors du rendu, ce qui pose problème dès que vous l’utilisez avec le rendu côté serveur.

Le piège du SSR : “window is not defined” et désaccord d’hydratation

Côté serveur, il n’y a pas de window ni de localStorage, donc lire le stockage lors du premier rendu lève une exception window is not defined dans Next.js et Remix. Protégez-vous avec typeof window === 'undefined' et lisez la valeur persistée après le montage.

Il existe un second bug, plus subtil, même après avoir corrigé le plantage. Un désaccord d’hydratation survient parce que le serveur affiche votre état par défaut tandis que le client possède déjà la valeur stockée ; le premier rendu client de React doit correspondre au HTML du serveur, donc si vous lisez localStorage dans l’initialiseur pendant l’hydratation, le balisage diverge. La solution consiste à afficher la valeur par défaut côté serveur, puis à synchroniser avec la valeur persistée dans un effet après l’hydratation.

const IS_SERVER = typeof window === 'undefined';

function useLocalStorage<T>(key: string, initialValue: T, initializeWithValue = true) {
  const readValue = () => (IS_SERVER ? initialValue : readJSON(key, initialValue));

  const [value, setValue] = useState<T>(() =>
    initializeWithValue ? readValue() : initialValue,
  );

  useEffect(() => {
    setValue(readValue()); // synchronisation depuis le stockage après le montage
  }, [key]);
  // ...setter comme précédemment
}

L’indicateur initializeWithValue reproduit le commutateur du hook useLocalStorage de usehooks-ts : passez-le à false pour le SSR afin que le hook retourne la valeur par défaut côté serveur et se synchronise après l’hydratation. Cette catégorie de bug est quasi invisible lors d’un chargement propre en localhost. Rejouer une vraie session de production est souvent la seule façon de rendre visible le flash d’hydratation (le thème par défaut qui s’affiche pendant une image avant que la valeur persistée prenne le relais), car il dépend du timing et de l’environnement plutôt que d’être reproductible à la demande.

Synchronisation entre onglets et l’approche moderne useSyncExternalStore

L’état persisté doit rester cohérent lorsqu’un utilisateur a deux onglets ouverts. L’événement storage du navigateur, décrit dans la documentation MDN de l’événement Window: storage, ne se déclenche que dans les autres onglets et documents, jamais dans celui qui a écrit la valeur. La synchronisation entre onglets nécessite donc un écouteur storage, et les écouteurs du même onglet ont besoin d’un événement personnalisé déclenché manuellement.

Pour du nouveau code, il existe une primitive plus propre que useState + effets. useSyncExternalStore a été introduit dans React 18 comme méthode officielle pour abonner un composant à un magasin mutable externe. Les composants lisent normalement depuis les props, l’état et le contexte, mais il arrive parfois qu’un composant doive lire une valeur qui vit en dehors de React et évolue dans le temps, notamment les API du navigateur qui détiennent une valeur mutable et émettent des événements lors de ses modifications. La référence officielle de React pour ce hook recommande l’état natif quand c’est possible et réserve ce hook principalement à l’intégration avec du code non-React existant. localStorage répond à ces critères, et c’est pourquoi les bibliothèques maintenues l’ont adopté pour des lectures sûres en mode concurrent et correctes entre onglets.

function useLocalStorageValue(key: string, initial: string) {
  const subscribe = (cb: () => void) => {
    window.addEventListener('storage', cb);
    return () => window.removeEventListener('storage', cb);
  };
  return useSyncExternalStore(
    subscribe,
    () => localStorage.getItem(key) ?? initial,
    () => initial, // snapshot serveur
  );
}

Faut-il implémenter useLocalStorage soi-même ou utiliser une bibliothèque ?

Implémentez-le vous-même lorsque vous avez besoin d’une seule valeur primitive côté client. Optez pour une bibliothèque maintenue lorsque vous avez besoin que la sérialisation des cas limites, le SSR et la synchronisation entre onglets soient gérés ensemble. Les deux options ci-dessous fonctionnent avec React 18 et 19. La version actuelle est React 19.2, publiée le 1er octobre 2025, avec les versions correctives 19.2.x depuis lors répertoriées dans le journal des modifications de React.

OptionIdéal pourGestion du SSRRemarques
Hook artisanalPrimitives ponctuelles, contrôle totalGuard typeof window + effet post-montageVous gérez les cas limites
usehooks-tsHook clé en main avec removeValueinitializeWithValue: falseBasé sur useState + événements, pas sur useSyncExternalStore
use-local-storage-stateExactitude entre onglets et en mode concurrentBasé sur useSyncExternalStoreTrès utilisé ; le mainteneur note que les composants en cours d’hydratation peuvent s’afficher deux fois

Voici le hook artisanal complet, correct sur React 18 et 19, avec initialisation paresseuse, JSON avec try/catch, guard SSR, mises à jour fonctionnelles, removeValue, et événements entre onglets et dans le même onglet :

import { useCallback, useEffect, useState } from 'react';

const IS_SERVER = typeof window === 'undefined';

type Options<T> = {
  serializer?: (value: T) => string;
  deserializer?: (value: string) => T;
  initializeWithValue?: boolean; // passer false pour le SSR
};

export function useLocalStorage<T>(
  key: string,
  initialValue: T,
  options: Options<T> = {},
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  const { initializeWithValue = true } = options;
  const serialize = options.serializer ?? JSON.stringify;
  const deserialize = options.deserializer ?? ((v: string) => JSON.parse(v) as T);

  const readValue = useCallback((): T => {
    if (IS_SERVER) return initialValue;
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? deserialize(raw) : initialValue;
    } catch {
      return initialValue;
    }
  }, [key, initialValue, deserialize]);

  const [storedValue, setStoredValue] = useState<T>(() =>
    initializeWithValue ? readValue() : initialValue,
  );

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        const next = value instanceof Function ? value(readValue()) : value;
        window.localStorage.setItem(key, serialize(next));
        setStoredValue(next);
        window.dispatchEvent(new StorageEvent('local-storage', { key }));
      } catch {
        /* quota dépassé ou mode privé — ignoré */
      }
    },
    [key, readValue, serialize],
  );

  const removeValue = useCallback(() => {
    window.localStorage.removeItem(key);
    setStoredValue(initialValue);
    window.dispatchEvent(new StorageEvent('local-storage', { key }));
  }, [key, initialValue]);

  // Synchronisation depuis le stockage après le montage (corrige l'hydratation SSR) et lors d'un changement de clé.
  useEffect(() => {
    setStoredValue(readValue());
  }, [key]); // eslint-disable-line react-hooks/exhaustive-deps

  // Écouteurs entre onglets ('storage') + dans le même onglet ('local-storage').
  useEffect(() => {
    const onChange = (event: Event) => {
      const e = event as StorageEvent;
      if (e.key && e.key !== key) return;
      setStoredValue(readValue());
    };
    window.addEventListener('storage', onChange);
    window.addEventListener('local-storage', onChange);
    return () => {
      window.removeEventListener('storage', onChange);
      window.removeEventListener('local-storage', onChange);
    };
  }, [key, readValue]);

  return [storedValue, setValue, removeValue];
}

Passez une initialValue stable (une primitive ou un objet mémoïsé) pour éviter que les dépendances de l’effet ne se recalculent à chaque rendu.

Persister l’état React est une progression par étapes, pas une solution en une ligne : commencez par un useState avec initialisation paresseuse et un effet d’écriture, ajoutez le try/catch JSON, protégez-vous pour le SSR, puis branchez les événements entre onglets. Déposez le hook ci-dessus dans un fichier hooks/ partagé, remplacez useState par ce hook pour les données d’état qui doivent survivre à un rechargement, et tournez-vous vers useSyncExternalStore ou une bibliothèque maintenue dès que la cohérence en mode concurrent entre onglets devient importante.

FAQ

Quelle est la différence entre localStorage et sessionStorage pour persister l'état React ?

Les deux sont des magasins clé/valeur synchrones, limités à la même origine, ne stockant que des chaînes, d'environ 5 Mo, mais ils diffèrent par leur durée de vie. localStorage persiste indéfiniment jusqu'à ce qu'il soit explicitement effacé, de sorte que l'état survit à un rechargement, à la fermeture d'un onglet et au redémarrage du navigateur. sessionStorage est limité à la session d'un seul onglet et est effacé à la fermeture de cet onglet ; il n'est pas partagé entre les onglets. Utilisez localStorage pour les préférences qui doivent survivre à la session et sessionStorage pour l'état transitoire propre à un onglet.

Pourquoi ne pas utiliser Redux Persist ou un magasin global pour persister un seul élément d'état ?

Recourir à un magasin global comme Redux Persist pour sauvegarder une seule valeur ajoute un magasin, un middleware et une configuration de sérialisation pour un état qu'un hook local gère déjà. Un hook useLocalStorage maintient la valeur colocalisée avec le composant qui en est propriétaire et reproduit l'ergonomie de useState, y compris les mises à jour fonctionnelles. Redux Persist justifie son poids lorsque vous utilisez déjà un magasin Redux et avez besoin de réhydrater des tranches entières, pas pour un bouton de basculement de thème ou un seul champ de formulaire.

Que se passe-t-il lorsque localStorage est plein ou désactivé en mode navigation privée ?

L'écriture dans localStorage lève une QuotaExceededError lorsque le quota d'origine d'environ 5 Mo est dépassé, et certains navigateurs lèvent une exception à toute écriture en mode privé ou incognito car le quota est fixé à zéro. Un setItem non protégé fait planter le composant, c'est pourquoi le setter d'un hook robuste encapsule les écritures dans un bloc try/catch. Les lectures doivent également revenir à la valeur par défaut afin qu'un magasin bloqué ou plein se dégrade en état en mémoire plutôt que de casser le rendu.

Est-ce que useSyncExternalStore remplace entièrement le pattern localStorage avec useState et useEffect ?

Pas dans tous les cas. useSyncExternalStore, ajouté dans React 18, est la méthode sûre en mode concurrent pour abonner un composant à un magasin mutable externe et constitue le bon choix lorsque la cohérence entre onglets et le rendu concurrent sont importants. La documentation officielle de React recommande l'état natif quand c'est possible et réserve ce hook à l'intégration de magasins non-React. Pour une seule primitive côté client, un useState avec initialisation paresseuse et un effet d'écriture reste plus simple et correct ; adoptez useSyncExternalStore lorsque les onglets doivent rester synchronisés.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.