How My React useLocalStorageState/localStorage Hook Works Internally
I built @gks101/localyx to make localStorage easier to use in React.
The main hook is called useLocalStorageState.
It lets you write code that feels like useState, but the value is also saved in the browser.
Package:
npm install @gks101/localyx
GitHub repo:
https://github.com/gaurav101/localyx
Working demo:
https://localyx.vercel.app/
In this article, I will explain how the hook works internally in simple steps.
This article is for React beginners who already know basic useState and useEffect.
The Basic Idea
Normal React state looks like this:
const [theme, setTheme] = useState("light");
But this value is lost when the page refreshes.
localStorage can save data after refresh, but using it directly can become messy.
So the hook gives us this:
const [theme, setTheme, clearTheme] = useLocalStorageState("theme", "light");
It returns:
-
theme: the current value -
setTheme: a function to update the value -
clearTheme: a function to remove the value
Internally, the hook connects React state with localStorage.
The Stored Data Shape
The hook does not save only the value.
It saves an object like this:
{
v: value,
t: timestamp
}
In the source code, this is called StorageEntry.
interface StorageEntry<T> {
v: T;
t?: number;
}
Here:
-
vmeans the real value -
tmeans the time when the value was saved
The timestamp is optional.
It is only needed when TTL expiry is used.
TTL means "Time To Live". It tells the hook when a value should expire.
The Options Object
The hook accepts an optional third argument.
useLocalStorageState("theme", "light", options);
The options include:
{
ttl?: number;
ttlStrategy?: "absolute" | "sliding";
namespace?: string;
onExpire?: (ctx) => void;
encrypt?: ((raw: string) => string) | null;
decrypt?: ((raw: string) => string) | null;
serializer?: {
stringify: (value) => string;
parse: (value) => value;
};
}
These options let the hook support expiry, namespaces, custom encoding, and custom parsing.
Browser Safety
localStorage only exists in the browser.
It does not exist during server-side rendering.
That is why the hook has a helper called isBrowser.
const isBrowser = (): boolean =>
typeof window !== "undefined" && typeof localStorage !== "undefined";
This protects the code from crashing in tools like Next.js.
Before reading or writing localStorage, the hook checks if it is running in the browser.
Safe Read, Write, and Delete Helpers
The hook uses small wrapper functions around localStorage.
function lsRead(key: string): string | null {
try {
return isBrowser() ? localStorage.getItem(key) : null;
} catch {
return null;
}
}
There are also helpers for writing and deleting.
Why use wrappers?
Because localStorage can fail in some cases.
For example:
- browser storage is disabled
- storage is full
- the app is running outside the browser
- the saved data is not valid
The wrapper functions make the hook safer.
Instead of crashing the app, the hook falls back to the initial value.
Creating the Final Storage Key
The hook supports namespaces.
This means the user can write:
useLocalStorageState("profile", null, {
namespace: "my-app",
});
Internally, the key becomes:
my-app:profile
The helper looks like this:
function resolveStorageKey(key: string, namespace?: string): string {
return namespace ? `${namespace}:${key}` : key;
}
This is useful when a project has many localStorage values.
It helps avoid key conflicts.
Encoding and Decoding the Value
Before the hook saves data, it turns the data into a string.
By default, it uses JSON:
JSON.stringify(entry)
Then it can encode the string.
By default, the hook uses Base64 encoding.
Base64 is not real encryption. It only makes the saved value less readable.
If someone needs real security, they should pass custom encrypt and decrypt functions.
The hook also allows plain JSON:
useLocalStorageState("theme", "light", {
encrypt: null,
decrypt: null,
});
When encrypt and decrypt are null, the hook skips encoding and decoding.
Reading the Value on First Render
This is one of the most important parts.
When the component first renders, the hook tries to read from localStorage.
const [state, setInternalState] = useState<T>(() => readStorage());
Notice that readStorage is passed as a function.
This is called lazy initialization.
It means React only calls readStorage during the first render.
This is better than reading from localStorage on every render.
Inside readStorage, the hook does these steps:
- Read the raw value from
localStorage. - If there is no value, return the initial value.
- Decode the value if needed.
- Parse the JSON.
- If parsing fails, delete the broken value.
- If the value is expired, delete it.
- If the value is valid, return
entry.v.
So the component receives the real saved value.
Handling Broken Data
Sometimes localStorage may contain broken data.
For example, maybe the value was edited manually in browser devtools.
The hook uses parseStorageEntry for this.
function parseStorageEntry(raw, decode, parse) {
try {
const decoded = decode ? decode(raw) : raw;
return parse(decoded);
} catch {
return null;
}
}
If parsing fails, it returns null.
Then the hook removes the invalid value and returns the initial value.
This keeps the app stable.
Updating the Value
The hook gives us a setter function.
setTheme("dark");
Internally, the setter does more work than normal useState.
It:
- Gets the next value.
- Wraps it in a storage entry.
- Adds a timestamp if TTL is enabled.
- Converts it to a string.
- Encodes it if needed.
- Saves it to
localStorage. - Updates React state.
- Sends a sync event.
The hook also supports updater functions:
setTheme((oldTheme) => oldTheme === "light" ? "dark" : "light");
This works like normal React state.
That makes the hook easier to learn because it follows a familiar pattern.
Removing the Value
The hook returns a third function called removeValue.
Example:
const [theme, setTheme, clearTheme] = useLocalStorageState("theme", "light");
When we call:
clearTheme();
The hook:
- Deletes the value from
localStorage. - Sends a sync event.
- Sets React state back to the initial value.
This is useful for reset buttons, logout buttons, and clearing old settings.
Same Tab Sync
This hook supports Same Tab Sync.
This means if two components on the same page use the same key, they can stay updated.
Example:
function Header() {
const [theme] = useLocalStorageState("theme", "light");
return <p>{theme}</p>;
}
function Settings() {
const [theme, setTheme] = useLocalStorageState("theme", "light");
return <button onClick={() => setTheme("dark")}>Dark</button>;
}
If Settings updates the theme, Header should also get the new value.
The browser storage event does not fire in the same tab that made the change.
So the hook creates its own custom event:
const LOCAL_SYNC_EVENT = "__localyx_sync__";
When the value changes, the hook dispatches this event.
Other hook instances in the same tab listen for it and update their state.
Cross Tab Sync
The hook also supports Cross Tab Sync.
This means if the app is open in two browser tabs, changes can move from one tab to another.
The browser already gives us a storage event for this.
The hook listens to it:
window.addEventListener("storage", onStorage);
When another tab changes the same localStorage key, this tab receives the new value.
Then the hook:
- Checks if the key is the same.
- Parses the new value.
- Checks if it is expired.
- Updates React state.
This makes the UI feel consistent across tabs.
TTL Expiry
TTL means a value should expire after some time.
Example:
const [token, setToken] = useLocalStorageState("token", null, {
ttl: 10 * 60 * 1000,
});
This means the value expires after 10 minutes.
When TTL is enabled, the hook saves a timestamp:
{
v: token,
t: Date.now()
}
Later, it checks:
now >= savedTime + ttl
If this is true, the value has expired.
Then the hook deletes it and returns the initial value.
Absolute TTL
The default TTL strategy is absolute.
Example:
useLocalStorageState("token", null, {
ttl: 10 * 60 * 1000,
ttlStrategy: "absolute",
});
If the value is saved at 10:00, it expires at 10:10.
It does not matter if the user keeps reading the value.
This is good for strict expiry.
Good examples:
- auth-like temporary values
- one-time data
- fixed cache expiry
Sliding TTL
The second TTL strategy is sliding.
Example:
useLocalStorageState("search_cache", "", {
ttl: 5 * 60 * 1000,
ttlStrategy: "sliding",
});
Sliding TTL refreshes the timestamp when the value is used.
In the code, this happens in touchSlidingTtl.
If the value is still active, the hook updates t to the current time.
This keeps active data alive for longer.
Good examples:
- active UI cache
- recent filters
- data that should expire only after inactivity
Auto Cleanup with setTimeout
The hook does not only check expiry when reading.
It also sets a timer.
window.setTimeout(...)
The timer waits until the saved value should expire.
When the timer runs, the hook reads the latest value again.
This is important because the value may have changed before the timer ended.
Then it checks if the latest value is expired.
If yes, it removes it and updates state.
This keeps the current tab clean without waiting for a page refresh.
The onExpire Callback
The hook lets users pass onExpire.
useLocalStorageState("token", null, {
ttl: 10 * 60 * 1000,
onExpire: ({ key, value }) => {
console.log("Expired:", key, value);
},
});
When the value expires, the hook calls this function.
This can be used to:
- show a message
- log out a user
- clear other related values
- track expiry behavior
The Internal expiryTick State
There is a small internal state called expiryTick.
const [expiryTick, setExpiryTick] = useState(0);
This value is not shown to the user.
It only helps the hook re-run the expiry timer effect.
When a TTL value changes or is removed, the hook updates expiryTick.
That tells React:
Please run the expiry effect again.
This keeps the timer in sync with the latest saved value.
Why useCallback Is Used
The hook uses useCallback for internal functions like:
readStoragesetStateremoveValueexpireEntrytouchSlidingTtl
useCallback helps keep function references stable between renders.
This is useful because some of these functions are used inside useEffect.
It helps React know when an effect should run again.
For beginners, the simple explanation is:
useCallback helps the hook avoid recreating important functions unless their inputs change.
The getRemainingTtl Helper
The package also exports getRemainingTtl.
Example:
import { getRemainingTtl } from "@gks101/localyx";
const remainingMs = getRemainingTtl("token", 10 * 60 * 1000);
Internally, it:
- Builds the final storage key.
- Reads the saved value.
- Decodes and parses it.
- Checks the saved timestamp.
- Returns the remaining time.
It can return:
- a number when time is left
-
0when the value has expired -
nullwhen there is no valid TTL data
This is useful when you want to show a timer or debug expiry behavior.
Full Flow in Simple Words
Here is the full flow of the hook:
- Build the final key.
- Choose JSON or custom serializer.
- Choose Base64 or custom encode/decode.
- Read old data from
localStorage. - Validate and parse the old data.
- Check if the old data is expired.
- Put the value into React state.
- When the setter is called, update state and
localStorage. - Notify other hook instances in the same tab.
- Listen for updates from other browser tabs.
- If TTL is enabled, schedule cleanup.
- If the value expires, remove it and reset state.
Small Example of the Final API
import { useLocalStorageState } from "@gks101/localyx";
function App() {
const [theme, setTheme, clearTheme] = useLocalStorageState("theme", "light", {
namespace: "demo-app",
ttl: 24 * 60 * 60 * 1000,
});
return (
<div>
<p>Theme: {theme}</p>
<button onClick={() => setTheme("dark")}>
Dark
</button>
<button onClick={() => setTheme("light")}>
Light
</button>
<button onClick={clearTheme}>
Reset
</button>
</div>
);
}
Why This Hook Is Useful
This hook is useful because it handles many small details in one place.
Without a hook, every component may need to repeat the same logic.
With this hook, the component only focuses on the UI.
The hook handles:
- saving
- loading
- parsing
- deleting
- expiry
- Same Tab Sync
- Cross Tab Sync
- custom serialization
- custom encoding
Final Thoughts
The main goal of useLocalStorageState is to make browser storage feel simple in React.
It behaves like useState, but it also remembers the value after refresh.
It also adds useful features like TTL expiry, same tab sync, cross tab sync, namespaces, and custom serialization.
If you are a beginner, you can first use the basic API.
Then, when your app needs more control, you can start using the options one by one.
Install:
npm install @gks101/localyx
GitHub:
https://github.com/gaurav101/localyx
Demo:
https://localyx.vercel.app/
Top comments (0)