Si Wei's Portfolio

Implement a type-safe persistent cache using the browser's Local Storage

14 min read
banner

Introduction

Local Storage is a powerful web browser feature that allows website (and users) to store and retrieve data from the user's device. This data is stored indefinitely until the user or website clears them themselves. It is implemented as a key-value pair storage where each key and value is a string.

You can see your application data stored on this website by pressing the F12 key and navigating to Application > Local Storage on Chrome or Storage > Local Storage on Firefox.

Example

Data stored in Local Storage is exclusive to the website domain, meaning data stored on YouTube will only be readable/mutable at youtube.com while data stored on Facebook will only be readable/mutable at facebook.com.

It is important to note that this implementation is only feasible if the data is not used for critical operations and that the Local Storage is the single source of truth. This is because users can freely manipulate the data stored in their Local Storage, and they would have to ability to cause major disruptions to services if these data are sensitive.

Some safe use cases are:

  1. Website user preferences
  2. Offline application data
  3. Persisting form data

In this post, I will show you how I made use Local Storage to store application data locally on users' devices. This eliminates the need for an account system, external database, and the external API call to fetch them.

I will use my web app, Route Planner, as an example. In my web app, users are able to add waypoints while the system finds the shortest path routes between them and calculates the distance. Locally, users are able to create multiple routes, manage them and edit them however they like.

You would notice that whenever you refresh the page, the routes remain saved and accessible. The app essentially reads from the Local Storage, parses the data and hydrates the state.

Example Data

interface RoutePrimitive {
  id: string;
  name: string;
  latlngs: [number, number][]; // LatLng of a specific point in a generated polyline path
  markerPos: [number, number][]; // LatLng of a marker placed by user
  distance: number;
  type: "bicycle" | "foot" | "mixed";
  short: string;
}

This is the interface of the Route that the user creates. It has all the information needed to construct a path and show markers where the user has placed. There are other metadata such as the route name, distance, and type. I want to persist multiple routes and for that we need to store an array of RoutePrimitive.

JavaScript's Web Storage API

JavaScript exposes a number of functions that can interact with the browser's Local Storage.

As a developer, you can access and invoke some of the functions through the global window object.

window.localStorage.setItem("hello", "world");

This sets the key hello at Local Storage to correspond to the value world.

const value = window.localStorage.getItem("hello"); // "world"
const value2 = window.localStorage.getItem("foo"); // null

If there is a value associated with the key provided, this function will return the string value. If there isn't, it will return null.

Storing JSON in Local Storage

From the example above, we can see that Local Storage only accepts strings as the key and value. We must convert the JSON objects into a string before storing.

const obj = [{
  id: "123";
  name: "Example Route";
  latlngs: [
    [1.0, 2.0],
    [2.0, 3.0],
    [3.0, 4.0],
  ];
  markerPos: [
    [1.0, 2.0],
    [3.0, 4.0],
  ];
  distance: 13012;
  type: "foot";
  short: "";
}]
const objString = JSON.stringify(obj); // [{id: "123";name: "Example Route";latlngs: [[1.0, 2.0],[2.0, 3.0],[3.0, 4.0],];markerPos: [[1.0, 2.0],[3.0, 4.0],];distance: 13012;type: "foot";short: "";}]

This objString will include all the JSON operators such as {} and ,. After converting the JSON into string, you can store it in Local Storage.

window.localStorage.setItem("routes", objString);

To retrieve the items

const objString = window.localStorage.getItem("routes");
const obj = JSON.parse(objString);

This is the most basic way of setting and retrieving JSON objects into Local Storage. There could be some cases such as

  1. Value does not exist at the given key
  2. Value exists, but is of wrong type or is corrupted

We can write some helper functions to help handle these edge cases. We want these functionalities

  1. Write/Overwrite at given key
  2. Retrieve the value at given key, ensuring it matches the supplied type
  3. If the value is an array, append and save to the Local Storage.

For the first, it is simple. Since we are writing/overwriting, we only have to ensure the input is the correct type.

const saveToStorage = <T>(key: string, item: T) => {
  const tmp = JSON.stringify(item);
  window.localStorage.setItem(key, tmp);
};

saveToStorage<RoutePrimitive[]>("routes", obj);

Next, we want to write a function that retrieves the value at given key. We must ensure the case when value doesn't exist, and if it does, it is of the correct format.

const getStorageValue = <T>(key: string, fallback: T) => {
  try {
    return JSON.parse(window.localStorage.getItem(key) ?? "") as T;
  } catch (e) {
    return fallback;
  }
};

const obj = getStorageValue<RoutePrimitive[]>("routes", []);

We supply the type RoutePrimitive[] during the function call. Within the function, it receives this RoutePrimitive[] type as a variable, T.

window.localStorage.getItem(key) ?? "" will return the value at key, and if it is empty, it will return an empty string.

as T will tell TypeScript to attempt to fit this returned value into the supplied type. If it is not possible, it will throw an error. Thus, we can catch this edge case using a try catch and handle this mismatch type. In my case, I would return a fallback value predefined at the function call.

For the last function, it is combination of the two. This is as easy to understand as the previous one.

const appendToStorage = <T>(key: string, item: T) => {
  try {
    const arr: T[] = JSON.parse(window.localStorage.getItem(key) ?? "[]");
    const tmp = [{ ...item }, ...arr];
    const result = JSON.stringify(tmp);
    window.localStorage.setItem(key, result);
  } catch (e) {
    console.log(e);
  }
};

appendToStorage<RoutePrimitive>("routes", obj[0]);

We first retrieve the value at the given key. If it is empty, we create a new empty array. If it is not, we attempt to parse it as an array of supplied type T. If say this value isn't an array, then it will throw an error. We include a try catch to handle this mismatch/corrupted error.

This isn't quite foolproof yet. The type-cast check as T doesn't assert properties in object types well, and the types of the array children may not be asserted. We have to use an external library Zod to validate our data to ensure 100% type-safety. More on that later, when we integrate this function into our application.

In this example, I will choose to log this error to the console. Other options include sending out a toast notification, or send the logs to an error tracking SaaS like Sentry

App Integration

With these helper functions defined, we can use them alongside our main application functions.

Initialising State

In modern JavaScript frameworks like React, there are built-in features called state to manage data within a component. State represents the mutable data that can be accessed and updated by a component. All changes are tracked and relevant components that uses them are re-rendered immediately. This feature makes it easy for users to create a reactive and responsive web application.

Local Storage is not considered a state as it is a part of the web browser API. To give our data state features, we need to initialise new states with the data retrieved from Local Storage.

There are many state management libraries such as Redux and Zustand. For this example, I will use a simple signal hook from SolidJS, which is similar to the useState hook from React.

const [routes, setRoutes] = createSignal<RoutePrimitive[]>([]);

This is how we iniitalise our web application in the first place. When the page refreshes, or a user enters a website, the routes is set to an empty array. We can use helper functions to initialise the state with cached data from Local Storage.

const [routes, setRoutes] = createSignal<RoutePrimitive[]>(
  getStorageValue<RoutePrimitive[]>("routes", [])
);

When the user enters the website for the first time, this signal is initialised immediately to the data stored at the Local Storage.

Type-safety

As mentioned earlier, TypeScript's type casting does not assert types at its best. We need to use a validation library like Zod to ensure parsed value from Local Storage is of the correct supplied type.

Whenever we retrieve data from Local Storage, we have to perform this validation check before initialising it into the state.

import z from "zod";

const LatLngZod = z
  .object({
    lat: z.number(),
    lng: z.number(),
  })
  .nonstrict();

const RoutePrimitiveSchema = z.array(
  z.object({
    id: z.string(),
    name: z.string(),
    latlngs: z.array(LatLngZod),
    markerPos: z.array(LatLngZod),
    distance: z.number(),
    type: z.enum(["foot", "bicycle", "mixed"]),
    short: z.string(),
  })
);

A Zod schema is a pre-defined blueprint of an interface. It can validate unknown data types and ensure that these input data conforms to the defined schema. Above is the schema for our array that contains the RoutePrimitive object.

const routes = getStorageValue<RoutePrimitive[]>("routes", []);
const validationResult = RoutePrimitiveSchema.safeParse(routes);
console.log(validationResult.success); // true

The safeParse method requires a variable of unknown data type. It will return an object that contains the success property, a boolean that tells you whether the given data conforms to the schema.

We can create a wrapper function that further validates our Local Storage value, before initialising it to the state.

const initialiseState = () => {
  const storedRoutes = getStorageValue<RoutePrimitive[]>("routes", []);
  const validationResult = RoutePrimitiveSchema.safeParse(storedRoutes);
  if (validationResult.success) {
    return storedRoutes;
  }
  return [];
};

const [routes, setRoutes] = createSignal<RoutePrimitive[]>(initialiseState());

State Management

The reason why we have a state object is because we need to keep track of its changes throughout the application's lifecycle, and update it's UI and behaviour. Since the state value is independent from the Local Storage, whenever we perform updates to the state, we need to do the same to our Local Storage value. This can be achieved through the saveToStorage and appendToStorage functions.

const newRoutes: RoutePrimitive[] = getNewRoutes(); // dummy example function
setRoutes(newRoutes); // state updater function
saveToStorage<RoutePrimitive[]>("routes", newRoutes); // equal update to Local Storage

We receive a new RoutePrimitive[] object from a dummy function (which simulates an app action). Whenever I want to update the app's state, I need to update the Local Storage as well, preferably within the same function call. Why? We want our application to perform like a write-through cache instead of a write-back cache.

Typically, in a write-back cache, there is deferred updating to the memory (or Local Storage) to improve performance. Data is only written back to the memory when the corresponding data at the cache is being purged.

Similarly, it would be intuitive to only write to Local Storage only when we close our application. This can be done through using built-in hooks like useEffect that invokes a callback whenever we unmount our application.

However, this isn't particularly safe. When we abruptly close the window, the callback may not perform and this would result in the permanent loss in data. It is recommended to always perform the update to the Local Storage alongside the update to our state.

At the same time, there isn't a need to create a Zod schema to validate the input to these functions, since the input is given directly from our internal application.

Conclusion

Local Storage can be a powerful web browser feature that allows websites to store and retireve data on the user's device. It operates as a key-value pair storage system and should only be used to store non-critical data such as user preferences and offline application data.

It is important to note that users can freely manipulate the data stored there, so sensitive information should still be handled differently like an external database. When retrieving data from the Local Storage, there should always be the assumption that data stored may not be correctly typed. Therefore, steps must be taken to ensure these data is sanitary before parsing it into our state.

In the context of a web application, data might not be synchronised correctly if we are adopting a write-back cache concept. It is recommended to write to memory (Local Storage) everytime the state updates to ensure no data loss.