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) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, 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.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue] as const;
}Am pățit-o de zeci de ori la început: scrii un hook simplu pentru localStorage, totul merge brici pe local, apoi îl urci pe un proiect de Next.js și te trezești cu consola plină de erori de hidratare. Problema e clasică. Serverul randează o versiune a paginii (fără acces la window), iar clientul încearcă să „lipească” starea din browser peste ce a venit de la server.
Dacă serverul zice că starea e null și browserul vede în storage că e dark-mode, React urlă că HTML-ul nu se potrivește. Am pierdut vreo 4 ore bune la un proiect cu 15k useri lunari doar încercând să înțeleg de ce layout-ul sărea aiurea la refresh. Soluția nu e doar un if (typeof window !== 'undefined'), ci o strategie de tip „two-pass rendering”.
De ce e periculos varianta naivă
Majoritatea tutorialelor de începători îți zic să pui o verificare de window direct în useState. E o capcană. Dacă faci asta, prima randare pe client va fi diferită de cea de pe server. Regula de aur în SSR este că prima randare pe client trebuie să fie identică cu cea de pe server. Orice logică de accesare a browser API-urilor (cum e Storage) trebuie să se întâmple după ce componenta s-a montat.
Am văzut implementări unde se folosea useLayoutEffect pentru a evita flickering-ul, dar pe server ăsta aruncă warning-uri. Cel mai sigur e să folosești un useEffect care să facă sync-ul după mount. Da, apare un mic „flicker” dacă valoarea din storage e diferită de default, dar e mult mai safe decât să crăpi aplicația.
Error handling și edge cases
Un alt lucru pe care mulți îl omit e try-catch-ul. localStorage poate să dea fail din motive absurde: userul e în Incognito pe un browser vechi, storage-ul e plin (da, se întâmplă la 5MB) sau pur și simplu JSON-ul salvat e corupt. Dacă nu prinzi eroarea la JSON.parse, tot hook-ul îți dă peste cap întreaga pagină.
La un magazin online la care am lucrat, am economisit cam 20% din tichetele de support legate de „setările nu se salvează” doar adăugând un log silențios în catch. Știam exact când browserul ne dădea cu flit.
Trade-off-ul onest
Folosind metoda cu useEffect, ai un dezavantaj: componenta se randează de două ori la mount. O dată cu valoarea default (pentru a face match cu serverul) și imediat după cu valoarea reală din disc. Pentru 99% din cazuri, utilizatorul nici nu observă. Dacă ai nevoie de viteză extremă și vrei să eviți asta, singura soluție reală e să muți starea respectivă într-un cookie și să o citești direct pe server, dar asta e deja altă discuție și mult mai mult boilerplate.
Pentru setări de UI, teme sau preferințe de filtrare, hook-ul de mai jos e „tanc”. E simplu, nu are dependențe externe și gestionează corect ciclul de viață al unei aplicații moderne de React.
Voi cum gestionați sincronizarea între tab-uri? Că dacă schimbi tema într-un tab, hook-ul ăsta simplu nu va anunța și celălalt tab fără un event listener de storage.