DEV Community

Cover image for Internals of useLocalStorageState Hook
Gaurav Kumar Singh
Gaurav Kumar Singh

Posted on

Internals of useLocalStorageState Hook

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
Enter fullscreen mode Exit fullscreen mode

GitHub repo:

https://github.com/gaurav101/localyx
Enter fullscreen mode Exit fullscreen mode

Working demo:

https://localyx.vercel.app/
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

In the source code, this is called StorageEntry.

interface StorageEntry<T> {
  v: T;
  t?: number;
}
Enter fullscreen mode Exit fullscreen mode

Here:

  • v means the real value
  • t means 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);
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

Internally, the key becomes:

my-app:profile
Enter fullscreen mode Exit fullscreen mode

The helper looks like this:

function resolveStorageKey(key: string, namespace?: string): string {
  return namespace ? `${namespace}:${key}` : key;
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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:

  1. Read the raw value from localStorage.
  2. If there is no value, return the initial value.
  3. Decode the value if needed.
  4. Parse the JSON.
  5. If parsing fails, delete the broken value.
  6. If the value is expired, delete it.
  7. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Internally, the setter does more work than normal useState.

It:

  1. Gets the next value.
  2. Wraps it in a storage entry.
  3. Adds a timestamp if TTL is enabled.
  4. Converts it to a string.
  5. Encodes it if needed.
  6. Saves it to localStorage.
  7. Updates React state.
  8. Sends a sync event.

The hook also supports updater functions:

setTheme((oldTheme) => oldTheme === "light" ? "dark" : "light");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

When we call:

clearTheme();
Enter fullscreen mode Exit fullscreen mode

The hook:

  1. Deletes the value from localStorage.
  2. Sends a sync event.
  3. 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>;
}
Enter fullscreen mode Exit fullscreen mode

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__";
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

When another tab changes the same localStorage key, this tab receives the new value.

Then the hook:

  1. Checks if the key is the same.
  2. Parses the new value.
  3. Checks if it is expired.
  4. 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,
});
Enter fullscreen mode Exit fullscreen mode

This means the value expires after 10 minutes.

When TTL is enabled, the hook saves a timestamp:

{
  v: token,
  t: Date.now()
}
Enter fullscreen mode Exit fullscreen mode

Later, it checks:

now >= savedTime + ttl
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

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);
  },
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

This keeps the timer in sync with the latest saved value.

Why useCallback Is Used

The hook uses useCallback for internal functions like:

  • readStorage
  • setState
  • removeValue
  • expireEntry
  • touchSlidingTtl

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);
Enter fullscreen mode Exit fullscreen mode

Internally, it:

  1. Builds the final storage key.
  2. Reads the saved value.
  3. Decodes and parses it.
  4. Checks the saved timestamp.
  5. Returns the remaining time.

It can return:

  • a number when time is left
  • 0 when the value has expired
  • null when 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:

  1. Build the final key.
  2. Choose JSON or custom serializer.
  3. Choose Base64 or custom encode/decode.
  4. Read old data from localStorage.
  5. Validate and parse the old data.
  6. Check if the old data is expired.
  7. Put the value into React state.
  8. When the setter is called, update state and localStorage.
  9. Notify other hook instances in the same tab.
  10. Listen for updates from other browser tabs.
  11. If TTL is enabled, schedule cleanup.
  12. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

GitHub:

https://github.com/gaurav101/localyx
Enter fullscreen mode Exit fullscreen mode

Demo:

https://localyx.vercel.app/
Enter fullscreen mode Exit fullscreen mode

Top comments (0)