Use LocalStorage Hook in React with TypeScript

main

I have a productivity app, and usually, I store things that can work without a back-end in the local storage. Here we have a hook that provides a state for a widget that plays focus sounds. It stores everything in the local storage, and I call it usePersistentStorage. It receives a key for local storage and an optional initial value.

const focusSoundsInitialState: FocusSoundsState = {
  isEnabled: false,
  mode: "compilation",
  compilationConfiguration: defaultFocusSoundsComilationConfiguration,
  configuration: {
    fire: 0.8,
    night: 0.2,
    seaside: 0.2,
  },
}

// inside of the component
const [state, setState] = usePersistentStorageValue(
  "focus-sounds",
  focusSoundsInitialState
)

In the hook, we have useStatewith initializer function anduseEffectthat listens for the state change and updates localStorage. In the initializer, we start with taking value from local storage. Then we check if it's an object and return either value from storage or the initial value.

import { useEffect, useState } from "react"

import { persistentStorage } from "./persistentStorage"

export function usePersistentStorageValue<T>(key: string, initialValue?: T) {
  const [value, setValue] = useState<T>(() => {
    const valueFromStorage = persistentStorage.getItem(key)

    if (
      typeof initialValue === "object" &&
      !Array.isArray(initialValue) &&
      initialValue !== null
    ) {
      return {
        ...initialValue,
        ...valueFromStorage,
      }
    }

    return valueFromStorage || initialValue
  })

  useEffect(() => {
    persistentStorage.setItem(key, value)
  }, [key, value])

  return [value, setValue] as const
}

There might be a situation when we want to use something different than localStorage, so we have an abstraction of persistent storage. It has two methods, one to get a value and another to set.

interface PersistentStorage {
  getItem(key: string): string | null
  setItem(key: string, value: any): void
}

class LocalStorage implements PersistentStorage {
  getItem(key: string) {
    const item = localStorage.getItem(key)

    if (item === null) return undefined

    if (item === "null") return null
    if (item === "undefined") return undefined

    try {
      return JSON.parse(item)
    } catch {}

    return item
  }
  setItem(key: string, value: any) {
    if (value === undefined) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(value))
    }
  }
}

class MockStorage implements PersistentStorage {
  getItem() {
    return null
  }
  setItem() {}
}

export const persistentStorage = window?.localStorage
  ? new LocalStorage()
  : new MockStorage()

If there's no local storage in the window, we can provide a fallback, but I don't worry about that. In the getItem, we have fancy checks. Sometimes we need to distinguish null from undefined. In the end, we return a parsed result. There could be something wrong with the format, so we wrap it with try-catch. If we want to change the format of the stored value, we can migrate by changing the key. One approach would be to update a date postfix of the key every time we want to migrate.