top of page
Search

Building a Type-safe localStorage Hook in React

  • Writer: Zac Butko
    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.


ree

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




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


ree




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

  1. No localStorage data exists already

  2. JSON cannot be parsed

  3. 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);

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.



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


bottom of page