Problem Statement
When building React applications that rely on localStorage for storing user preferences like timezone settings, you might encounter a common issue: changes made to localStorage don't trigger UI updates in components that depend on that data. The stored values only become visible after a page refresh, which creates a poor user experience when multiple components need to share and react to the same state.
This article explores several approaches to solving this reactivity problem, including one that actually works.
Initial Approach: Using localStorage as a Dependency
A natural first attempt might be to use localStorage.getItem() direct as a dependency in a useEffect hook:
useEffect(() => {
console.log(11111, localStorage.getItem("timezone"));
}, [localStorage.getItem("timezone")]);
This approach fails because localStorage.getItem("timezone") returns a new string reference on every render. React's dependency comparison uses Object.is, so this causes the effect to run unnecessarily or behave unpredictably. According to the React documentation, the dependencies array should contain stable references like props and state values, not expressions that change on each render.
Second Attempt: The storage Event
The next logical approach is to listen for changes using the storage event on the window object. This evant fires when localStorage is modified in another document sharing the same origin:
// useLocalStorageListener.js
import { useState, useEffect } from "react";
const useLocalStorageListener = (key) => {
const [value, setValue] = useState(() => localStorage.getItem(key));
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key) {
setValue(event.newValue);
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, [key]);
return [value];
};
export default useLocalStorageListener;
However, this solution also fails when you need to detect changes within the same page. The storage event only communicates across different browser tabs or windows that share the same origin. Changes made in the current tab won't trigger this event in the current context.
Working Solution: Intercepting setItem
The key insight is that we need to intercept writes to localStorage within the same page and notify our React components. This can be achieved by wrapping the native setItem method with a custom implementation that dispatches custom events:
// useReactiveStorage.js
import { useState, useEffect } from "react";
function useReactiveStorage(storageKey) {
// Validate the storage key
if (!storageKey || typeof storageKey !== "string") {
return [null];
}
const [currentValue, setCurrentValue] = useState(
localStorage.getItem(storageKey)
);
useEffect(() => {
// Store reference to the original setItem method
const originalSetItem = localStorage.setItem;
// Override setItem to emit custom events
localStorage.setItem = function (key, newValue) {
const changeEvent = new CustomEvent("localStorageChanged", {
detail: { key, newValue },
});
window.dispatchEvent(changeEvent);
originalSetItem.apply(this, [key, newValue]);
};
// Handler for the custom event
const handleStorageChange = (event) => {
if (event.detail.key === storageKey) {
setCurrentValue(event.detail.newValue);
}
};
window.addEventListener("localStorageChanged", handleStorageChange);
// Cleanup: remove listener and restore original method
return () => {
window.removeEventListener("localStorageChanged", handleStorageChange);
localStorage.setItem = originalSetItem;
};
}, [storageKey]);
return [currentValue];
}
export default useReactiveStorage;
Usage Example
Here's how you can use this hook to create a reactive timezone hook:
// useTimezone.js
import { useState, useEffect } from "react";
import useReactiveStorage from "./useReactiveStorage";
import { getUserTimezone, timezoneKey } from "@/utils/timezone";
function useTimezone() {
const [timezone, setTimezone] = useState(() => getUserTimezone());
const [storedValue] = useReactiveStorage(timezoneKey);
useEffect(() => {
setTimezone(() => getUserTimezone());
}, [storedValue]);
return [timezone];
}
export default useTimezone;
In your component, simply use the hook:
import useTimezone from "@/hooks/useTimezone";
function SettingsPanel() {
const [timezone] = useTimezone();
useEffect(() => {
console.log("Timezone updated:", timezone);
}, [timezone]);
return <div>Current timezone: {timezone}</div>;
}
When any part of your application updates the timezone in localStorage, all components using this hook will automatically receive the new value without requiring a page refresh.
Summary
Making localStorage reactive in React requires intercepting the native setItem method and dispatching custom events that React components can listen to. While other state management solutions like global stores could achieve similar results, this approach maintains the simplicity of using localStorage while adding the necessary reactivity layer.