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:
- Next.js lo uccide a vista.
localStoragenon 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. - I dati corrotti lanciano eccezioni. Lo storage è modificabile dall'utente e sopravvive ai tuoi deploy. Basta una voce malformata e
JSON.parsetira giù l'albero dei componenti. - 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. - 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. - 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.