import { useSyncExternalStore, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const getSnapshot = () => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
};
const getServerSnapshot = () => initialValue;
const subscribe = (callback: () => void) => {
const onStorage = (e: StorageEvent) => {
if (e.key === key || e.key === null) callback();
};
window.addEventListener('storage', onStorage);
window.addEventListener('local-storage-change', callback);
return () => {
window.removeEventListener('storage', onStorage);
window.removeEventListener('local-storage-change', callback);
};
};
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = useCallback((val: T | ((prev: T) => T)) => {
try {
const current = getSnapshot();
const next = val instanceof Function ? val(current) : val;
window.localStorage.setItem(key, JSON.stringify(next));
window.dispatchEvent(new Event('local-storage-change'));
} catch (err) {
console.warn(`Error setting localStorage key "${key}":`, err);
}
}, [key]);
return [value, setValue] as const;
}Dacă lucrezi cu Next.js sau Remix, sigur te-ai lovit măcar o dată de ecranul roșu cu Hydration failed because the server-rendered HTML didn't match the client. La un proiect de anul trecut, un panou intern cu vreo 14k useri activi, aveam tema salvată în localStorage și consola arăta ca un pom de Crăciun din cauza warning-urilor.
Problema apare mereu din același motiv: pe server nu există window sau localStorage. Serverul randează o valoare de fallback (de exemplu tema light), iar browserul găsește dark pe mașina utilizatorului. HTML-ul diferă, reconcilierea crapă.
De ce abordarea clasică e o capcană
Mulți juniori încearcă hack-ul cu typeof window !== 'undefined' ? localStorage.getItem(...) : fallback direct în argumentul de la useState. Pare logic la prima vedere, dar este greșit. Pe server componenta se randează cu fallback-ul, iar la primul render pe client se va randa direct cu valoarea din storage. React observă diferența instantaneu și aruncă eroarea.
Ca să rezolvi asta elegant, ai două căi: un state suplimentar de mounted sau useSyncExternalStore (introdus în React 18). A doua variantă e mult mai curată fiindcă a fost creată exact pentru surse externe de date din afara React-ului.
Gotcha-uri reale din producție
Înainte să arunci codul în proiect, sunt trei chestii practice de care m-am lovit:
- Event-ul de storage nu se declanșează în tab-ul curent. Dacă scrii în
localStorage, browserul emite evenimentulstoragedoar către celelalte tab-uri deschise. Ca să sincronizezi componentele din aceeași pagină, trebuie să emiți manual unCustomEventcând faci update. - Safari în Private Browsing sau storage plin. Dacă utilizatorul are
localStorageblocat sau cota de 5MB e atinsă,setItemaruncă eroare (QuotaExceededError). Un simplutry/catchte scapă de un ecran alb neașteptat. - Flicker-ul vizual (Flash of Unstyled Content). Dacă folosești hook-ul pentru temă sau date critice de layout, utilizatorul va vedea o fracțiune de secundă valoarea de pe server înainte de actualizare. Pentru teme vizuale, cel mai sănătos rămâne un script minuscul blocant de JS în
<head>. Pentru orice altceva (filtre, preferințe de dashboard, token-uri non-critice), hook-ul e perfect.
Trade-off sincer
Abordarea cu useSyncExternalStore cere un pic mai mult boilerplate decât un useState naiv învelit într-un useEffect. În schimb, scapi garantat de re-randări inutile, ai cod predictibil pe SSR și nu mai vezi niciodată hydration mismatch în consolă.
Voi cum gestionați preferințele de UI persistate pe client: script blocant în HTML sau vă bazați pe hook-uri după montare?