import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item !== null) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.warn(`Eroare la citirea cheii "${key}" din localStorage:`, error);
}
}, [key]);
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.warn(`Eroare la salvarea cheii "${key}" în localStorage:`, error);
}
};
return [storedValue, setValue] as const;
}Câți dintre voi n-au văzut eroarea aia roșie urâtă în consolă când ați folosit window.localStorage într-un proiect de Next.js sau Remix? "Text content did not match server-rendered HTML". Am trecut prin asta acum vreo doi ani când migram o aplicație cu 20k utilizatori lunari de pe Create React App pe Next.js 13.
De ce crapă codul naiv pe server
Când scrii const [val] = useState(() => localStorage.getItem('theme')), Node.js nu știe ce e ăla window. Îți crapă direct la build cu ReferenceError: window is not defined.
Dacă pui un typeof window !== 'undefined' direct în useState, scapi de crash-ul la build, dar dai peste altă belea: hydration mismatch. Serverul generează HTML-ul folosind valoarea de fallback (să zicem "light mode"), iar clientul citește din localStorage la primul render și vrea "dark mode". React vede că markup-ul trimis de server nu bate cu ce a generat clientul la prima strângere de mână și aruncă eroarea în consolă.
Soluția curată: două randări asumate
Regula de aur în SSR este simplă: primul render pe client trebuie să fie 100% identic cu render-ul de pe server.
Asta înseamnă că starea inițială trebuie să fie ÎNTOTDEAUNA initialValue, indiferent ce ai tu salvat în browser. Abia după ce componenta s-a montat în DOM (adică în interiorul unui useEffect), avem voie să citim din localStorage și să facem update la stare.
Trade-off-ul este evident: vei avea o re-randare suplimentară pe client imediat după mount. Dacă folosești hook-ul pentru o temă vizuală, utilizatorul va vedea un scurt flash alb înainte să treacă pe dark mode (lucru care se rezolvă de obicei cu un script inline blocant în <head>). Dar pentru setări de dashboard, opțiuni de filtrare sau preferințe de interfață, este abordarea absolut sigură.
Gotcha-uri din producție de care m-am lovit
- Sincronizarea între tab-uri: Dacă userul are aplicația deschisă în două tab-uri și schimbă o setare în primul, al doilea tab nu se actualizează automat decât dacă asculți pe evenimentul nativ
storage. Am adăugat asta după ce clienții se plângeau că schimbau limba într-un tab și rămânea veche în celălalt. - Serializarea datelor:
JSON.parseva arunca excepții fatal dacă cineva a modificat manual valoarea din DevTools sau dacă e un string neformatat JSON. Pune mereu citirea și scrierea într-un bloctry...catch. - Modul Incognito din Safari: În anumite versiuni de Safari sau când spațiul de stocare e plin,
setItemaruncă eroare de cota depășită. Fără un catch acolo, aplicația ta crapă la un simplu click.
Alternative mai moderne
În React 18 a apărut useSyncExternalStore. E un hook excelent creat special pentru a conecta stări externe la React fără hydration mismatch. Totuși, pentru 90% din cazuri, varianta clasică bazată pe useEffect rămâne cel mai ușor de înțeles și depanat de către colegii mai la început de drum.
Voi cum gestionați persistența în localStorage când lucrați cu SSR? Folosiți un hook custom sau lăsați o librărie de state management ca Zustand să se ocupe de asta?