Vai al contenuto
LUIGI MICCA

Pubblicato

3 min di lettura

Stato persistente in React e Next.js, senza store

localStorage più useState sembrano sei righe di codice — finché non arrivano SSR, tab, dati stantii e JSON corrotti. Cosa serve davvero per una persistenza da produzione in React, e la scorciatoia da 1,4 kB.

Da qualche parte, in ogni codebase React, c'è un hook chiamato useLocalStorage: scritto in dieci minuti, copiato da un gist, e a cui vengono affidati il tema, il token di autenticazione e mezzo checkout. Ho fatto abbastanza audit di front-end da poterlo enunciare come legge di natura.

Ecco che cosa sta davvero promettendo quell'hook innocente — e perché la "persistenza" è un problema di ingegneria vero, non un ripensamento da useEffect.

Le sei righe che scrivono tutti

function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(
    () => JSON.parse(localStorage.getItem(key) ?? 'null') ?? initial
  );
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  return [value, setValue] as const;
}

Cinque problemi da produzione, in ordine di quanto tardi mordono:

  1. Next.js lo uccide a vista. localStorage non esiste sul server; e anche con le clausole di guardia, leggerlo durante il render rende il primo render client diverso dall'HTML del server — benvenuti warning di hydration mismatch.
  2. I dati corrotti lanciano eccezioni. Lo storage è modificabile dall'utente e sopravvive ai tuoi deploy. Basta una voce malformata e JSON.parse tira giù l'albero dei componenti.
  3. I componenti non si parlano. Due componenti sulla stessa chiave tengono ognuno la propria copia di useState: ne cambi uno, l'altro resta fermo. La "soluzione" tipica è un Context — un provider intero, per una stringa nello storage.
  4. Le tab non si parlano, i valori marciscono. Niente evento storage, niente scadenze: la bozza del mese scorso e il token di ieri vengono spacciati per freschi.
  5. Tempeste di scritture. Persistere un campo di testo a ogni tasto martella un'API sincrona sul main thread.

Niente di esotico. È la stessa checklist, in ogni app, di solito riscoperta un incidente alla volta.

La checklist, impacchettata

È l'esigenza da cui nasce smart-state, un piccolo hook open source che mantengo io (~1,4 kB min+gzip, zero dipendenze — piena trasparenza: è mio):

import { useSmartState } from 'smart-state';

const [theme, setTheme] = useSmartState<'light' | 'dark'>('light', {
  persist: true,
  storageKey: 'theme',
  syncTabs: true,
});

Semantica identica a useState — initializer lazy, updater funzionale — con tutta la checklist gestita: hydration sicura in SSR (niente warning), stato condiviso tra tutti i componenti sulla stessa chiave senza provider (sotto c'è useSyncExternalStore), sync tra tab, e una gestione errori che degrada invece di lanciare.

I dettagli da produzione sono opzioni, non riscritture:

// Lo storage è input non fidato: validalo
const [user, setUser] = useSmartState<User>(guest, {
  persist: true,
  storageKey: 'user',
  parse: userSchema.parse, // zod, valibot, qualsiasi cosa lanci
  version: 2,
  migrate: (old, from) => (from === 1 ? upgradeV1(old) : undefined),
});

// Una sessione che scade da sola
useSmartState('', { persist: true, storageKey: 'token', ttl: 15 * 60 * 1000 });

// Una bozza che non martella lo storage a ogni tasto
useSmartState('', { persist: true, storageKey: 'draft', writeDebounce: 300 });

Il trio parse/version/migrate è la parte a cui tengo di più: i dati persistiti attraversano i confini delle tue release, quindi meritano la stessa diffidenza di qualsiasi input esterno. Pochissime librerie li trattano così.

"Ma non basterebbe Zustand?"

Angolo dell'onestà: se la tua app ha stato condiviso genuinamente complesso — tanti punti di scrittura, dati derivati, time-travel nei devtools — uno store come Zustand o Redux Toolkit si guadagna il posto, e il middleware persist esiste. Ma adottare uno store perché ti serve localStorage è il mondo al contrario: ti carichi un'architettura per ottenere un effetto collaterale. Per il tema, i filtri, la bozza e il carrello, un hook di persistenza è la taglia giusta — e a 1,4 kB costa meno del readme dello store.

È la stessa lezione che ripeto nelle revisioni di architettura: prima dai un nome al requisito vero. "Condividere", "persistere", "sincronizzare" e "fare cache" sono quattro problemi diversi che nelle riunioni si chiamano tutti "state management".


Sono un software architect indipendente con focus sul front-end — smart-state ha fratelli per Vue e Nuxt che ne condividono il formato di storage. Se la tua codebase React ha tre useLocalStorage artigianali e un warning di hydration che nessuno indaga, parliamone: la call conoscitiva è gratuita.