Building a Type-safe localStorage Hook in React
- Zac Butko
- Aug 5, 2023
- 7 min read
Building a website can be fun, but sometimes the frontend runs ahead of the backend. Let's say we're making an app and want to add the ability to add several types of fruit, each with their own attributes, but we don't have the backend API up and ready yet. In this case, we have a pretty good idea of what we want the interface to look like and can get a 90% solution by utilizing localStorage.

What is localStorage?
localStorage, or window.localStorage, is one of the browser's solutions to storing app data for websites you visit. Unlike its less permanent cousin sessionStorage which gets cleared every time a new session starts (for instance, when you reload the page) localStorage remains in the browser cache between sessions and has no expiration date. This means anytime someone comes back to your website on the same browser, they will see all the data that they had before.
When developing an app localStorage is a very useful tool. Often to track down bugs or imitate first page loads we will reload the page. localStorage persists the data so it is a good analog for users returning to the site after logging out for the day. However it does have its shortfalls. If a different user logs into the site using the same machine and browser (shared family computer, public computer), they will get access to all of the previous user's data. Aside from the user experiencing buggy behavior from this (I don't remember having this data!) this could potentially be a very serious data leak. For this reason never store personal or confidential information in localStorage. Additionally, if a user changes computers or browsers they wont' have access to the data they created on the first machine/browser. Ideally we want a user to have the same experience with our website no mater where they access it from. For both of these reasons we want to eventually transition to a backend solution.
The goal - a fully generic and type-safe localStorage adapter
By the end of this exercise we want to have a fully generic and type-safe adapter we can always call on to help us save and retrieve data.
Start - Persisting Data
Let's start with a very simple use case - saving the results of an incremental counter. We'll start by spinning up a new CodeSandbox container, using the template "React TypeScript".
Note: the "React TypeScript" template is using the no-longer-supported "create-react-app", so some of the packages are out of date. For the purpose of this example, however this template will work fine.
To make the counter we'll use useState, hook it up to a button, and have a global reset function which will naively just reload the page.
Setting and getting from localStorage is very straightforward
// setting item
window.localStorage.setItem('count', newCount);
// getting item
const count = window.localStorage.getItem('count);We'll merge this into the the existing useState logic so that the app simultaneously saves our value and updates the DOM.
const initialCount =
(JSON.parse(window.localStorage.getItem("count") as string) as number) ?? 0;
const [count, setCount] = useState<number>(initialCount);
const increment = () => {
const newCount = count + 1;
setCount(newCount);
window.localStorage.setItem("count", JSON.stringify(newCount));
};The result is that count remembers its value across page reloads. Now in order to clear the app data we need to both remove the localStorage item and reload the app
(full source code: https://github.com/ZacButko/type-safe-localStorage-adapter/tree/start-persisting-data-2 )
Abstracting A Reusable localStorage Hook
From the very start we know that we want to reuse the localStorage adapter all over our site. This means moving the logic into a hook and exposing a setter and getter.
We'll create a new useLocalStorage.ts file and move all of our state logic there
export const useLocalStorage = <T>(storageName: string, defaultValue: T) => {
const initialData =
(JSON.parse(window.localStorage.getItem(storageName) as string) as T) ??
defaultValue;
const [data, setData] = useState<T>(initialData);
const persist = (newData: T) => {
setData(newData);
window.localStorage.setItem(storageName, JSON.stringify(newData));
};
return { data, persist };
};This in turn makes our Counter component much cleaner
const Counter = () => {
const { data: count, persist } = useLocalStorage<number>("count", 0);
const increment = () => {
persist(count + 1);
};
return (
<div className="Counter">
<div className="Counter-text">Count: {count}</div>
<div className="Counter-button">
<button onClick={increment}>+</button>
</div>
<div>{count % 2 ? "odd" : "even"}</div>
</div>
);
};Let's start using the generic type to our benefit by creating a new widget of different type
const fruits = [
"apple",
"orange",
"strawbery",
"banana",
"blueberry",
"pomello",
] as const;
type Fruit = (typeof fruits)[number];
export const Fruits = () => {
const { data: fruit, persist } = useLocalStorage<Fruit>("fruits", fruits[0]);
return (
<div className="Fruits">
<div>Current Fruit: {fruit}</div>
<div>
<button
onClick={() =>
persist(fruits[(fruits.indexOf(fruit) + 1) % fruits.length])
}
>
Next Fruit
</button>
</div>
</div>
);
};At this point if we inspect localStorage we see that we are correctly storing two keys of different values

(full source code: https://github.com/ZacButko/type-safe-localStorage-adapter/tree/reusable-localStorage-hook )
Finishing Up - Catching Errors and Future Proofing
Saving to and from localStorage is useful, but also lacking many of the protections and safe-guards we would get from a backend.
Catching Deserialization Errors
With an database backed API we can assume that the data from the server is well-formed. Because localStorage can accept any value there is a chance that it is not well formed, and when we go to parse it it would crash our app.
There are three cases to look out for, including one we have already solved
No localStorage data exists already
JSON cannot be parsed
Data is not in the format we expect
We've already gotten around the first case by requiring a "default value" in the useLocalStorage hook. If this is the user's first time to the site we ignore the undefined response from localStorage.getItem() and instead fallback to the specified default
JSON is structured string data. If the string value in localStorage is somehow tampered with (presumably by the developer experimenting) then there is a risk that parsing JSON will fail, and cause the app to crash. In this case we want to catch and warn the user that malformed data was found, and save the app by instead falling back on defaults.
let initialData = defaultValue;
try {
const localStorageData = JSON.parse(
window.localStorage.getItem(storageName) as string
) as T;
if (localStorageData) {
initialData = localStorageData;
}
} catch (e) {
console.error(`Failed to parse localStorage of storage ${storageName}`);
}
const [data, setData] = useState<T>(initialData);(full source code: https://github.com/ZacButko/type-safe-localStorage-adapter/tree/try-catch-json-parse )
For the third case there is a possibility that localStorage data was modified, or that our data structure has changed between when the user last visited (and saved data) and the next time they log in (and now the app expects a different format). This one is tricky to solve, since TypeScript is a static type checker, and provides no mechanisms for verifying type-safety of incoming data. To solve this we can do exhaustive type checking on every parsed data structure, however this is non-trivial to make truly generic. In lieu of that exercise, I prefer a much more simple approach of using a "version" system to let my app know when the localStorage might be out of date. For more on that, see the next section "Future Proofing Data Structures".
What about serializing errors? Because we use TypeScript we don't need to worry about serialization errors 😀
Future Proofing Data Structures
As our app grows we might decide that our resource could benefit from a change in schema. Normally these changes happen in concert with backend migrations to make sure all data shakes hands correctly. Because data is only stored locally, we need to insure consistency with our app against itself. The easiest way to do this I've found is to simply version the local storage. When the version number changes, you app knows to dump the data and use the new format instead. To support this we version each of our localStorage items and the structure for the storage evolves to match the new interface 'IStorageItem'
interface IStorageItem<T> {
version: string;
data: T;
}and checking that the version matches in the useLocalStorage hook
export const useLocalStorage = <T>(
storageName: string,
defaultValue: T,
version = "1.0.0"
) => {
let initialData = defaultValue;
try {
const localStorageData = JSON.parse(
window.localStorage.getItem(storageName) as string
) as IStorageItem<T>;
if (
localStorageData &&
localStorageData.version === version &&
localStorageData.data
) {
initialData = localStorageData.data;
}
...
const persist = (newData: T) => {
setData(newData);
const newStorageData: IStorageItem<T> = { version: version, data: newData };
window.localStorage.setItem(
fullStorageName,
JSON.stringify(newStorageData)
);
};Now let's evolve our data structure for "Fruits" to include both a name and description
interface IFruitData {
name: Fruit;
description: string;
}Using the new structure in localStorage is as simple as
const { data: fruit, persist } = useLocalStorage<IFruitData>(
"fruits",
fruitsList[0],
"1.0.1"
);Furthermore, when we push this to remote devices (other testers, etc). We know that the new version number will invalidate the previous cache and cause no app problems.
(full source code: https://github.com/ZacButko/type-safe-localStorage-adapter/tree/version-storage )
Future Proofing New Apps
Taking this one step further, on local development we often host the app locally at localhost:3000. This is fine, except for the fact that to our browser all local apps hosted this way look the same, and therefore autofill, passwords, and yes, localStorage are all shared by every app hosted on localhost:3000. To safeguard against data from one app leaking into another we will prepend all storage items with an app identifier so that names don't conflict. Furthermore, when we reset app data, we know to only look for those keys from this app, so clearing this site's data doesn't affect our other in-development projects.
export const APP_NAME = "LocalStorageApp";
const fullStorageName = `${APP_NAME}::${storageName}`Finally we make a better reset function that only affects this app's namespace.
export const resetAppData = () => {
Object.keys(window.localStorage).forEach((key) => {
if (key.startsWith(`${APP_NAME}::`)) {
window.localStorage.removeItem(key);
}
});
};The final product is something we can use for all of our apps no matter where they are.
Conclusion
We developed a clean, reusable, generic, and type-safe hook to get and retrieve app data from localStorage. This hook helps us rapidly develop new frontend tools without having to wait for the backend or api to be ready. Because we added versioning we can even mutate the data objects without risk of causing the app to crash when deserializing, and using TypeScript ensures we don't make mistakes while serializing.
Hopefully this code can help you understand more about React hooks, TypeScript, localStorage, and some good techniques for abstracting reusable code.
For the final source code checkout the GitHub repo https://github.com/ZacButko/type-safe-localStorage-adapter
For more articles like this checkout butko.io/blog
Thanks for reading 😊

Comments