Utilizing Local Storage in React Components: A Comprehensive Guide

September 12, 2023

7 min read

Utilizing Local Storage in React Components: A Comprehensive Guide
Watch on YouTube

Using local storage in React components may not be as straightforward as it seems. After years of React development, I've finally arrived at a bulletproof solution which I'm happy to share.

usePersistentState hook

Let's examine the solution from top to bottom. We'll interact with the local storage through the usePersistentState hook or the managePersistentState function. We can think of the usePersistentState hook as a variant of the useState hook, which maintains the value across remounts and app visits. To use the hook, we need to provide the value type, key, and initial value.

const [prefferedTheme, setPrefferedTheme] = usePersistentState<ThemePreference>(
  PersistentStateKey.ThemePreference,
  "dark"
)

Though the code may seem somewhat lengthy, it can always be encapsulated within a custom hook for convenience:

const useThemePreference = () => {
  return usePersistentState<ThemePreference>(
    PersistentStateKey.ThemePreference,
    "dark"
  )
}

// in component
const [prefferedTheme, setPrefferedTheme] = useThemePreference()

managePersistentState function

There are situations where our component doesn't need to listen for changes of the local storage value, in those cases we simply want to either save or read the value from the local storage at specific moments.

import { useRouter } from "next/router"
import { useCallback } from "react"
import { Path } from "router/Path"
import {
  PersistentStateKey,
  managePersistentState,
} from "state/persistentStorage"

const persistentPath = managePersistentState<string>(
  PersistentStateKey.PathAttemptedWhileUnauthenticated
)

export const useAuthRedirect = () => {
  const { replace, pathname } = useRouter()

  const toAuthenticationPage = useCallback(() => {
    persistentPath.set(pathname)
    replace(Path.SignIn)
  }, [pathname, replace])

  const toAuthenticatedPage = useCallback(() => {
    persistentPath.get()
    const destination = persistentPath.get() ?? Path.Home
    replace(destination)
    persistentPath.set(undefined)
  }, [replace])

  return {
    toAuthenticationPage,
    toAuthenticatedPage,
  } as const
}

For instance, within the useAuthRedirect hook, we may want to save the path accessed by an unauthenticated user. This would enable redirection to that same path after logging in. The managePersistentState function will return an object with get and set methods to read from, and write values to, local storage. We need to provide the value type and the key, but the initial value isn't necessary.

PersistentStateKey enum

In both the usePersistentState hook and the managePersistentState function, we use the PersistentStateKey enum. This enum contains all the keys used to store values in local storage. The idea is to keep all keys in one place to avoid inadvertently using the same key for different values.

export enum PersistentStateKey {
  OnboardedToBreak = "onboarded-to-break",
  AuthToken = "token",
  AuthTokenExpirationTime = "tokenExpirationTime",
  FocusDuration = "focusDuration",
  HasTimerSoundNotification = "hasTimerSoundNotification",
  HasTimerBrowserNotification = "hasTimerBrowserNotification",
  HasBreakBrowserNotification = "breakBrowserNotification",
  HasBreakSoundNotification = "breakSoundNotification",
  HasBreakAutomaticBreak = "hasBreakAutomaticBreak",
  UnsyncedSets = "unsyncedSets",
  LastPromptToKeepWorkingWasAt = "lastPromptToKeepWorkingWasAt",
  HadFocusOnboarding = "hadFocusOnboarding",
  FocusSounds = "focus-sounds-5",
  SidebarInstallPromptWasRejectedAt = "sidebarInstallPromptWasRejectedAt",
  SelectedSleepAdvice = "selectedSleepAdvice",
  ThemePreference = "themePreference",
  YesterdayRetroWasAt = "yesterdayRetroWasAt",
  LastWeekRetroWasAt = "lastWeekRetroWasAt",
  SupportOnboardingWasAt = "supportOnboardingWasAt",
  BreakEducationWasAt = "breakEducationWasAt",
  TwoDayRuleEducation = "twoDayRuleEducationWasAt",
  FocusDurationEducationWasAt = "focusDurationEducationWasAt",
  FocusSoundsView = "focusSoundsView",
  ScheduleEducationWasAt = "scheduleEducationWasAt",
  HabitsEducationWasAt = "habitsEducationWasAt",
  PathAttemptedWhileUnauthenticated = "pathAttemptedWhileUnauthenticated",
  ReactQueryState = "reactQueryState",
}

A union type could be used for the keys, but at some point we might need to change the data format of a value and therefore need a version for the key.

persistentState file

Let's dive deeper into the implementation. Throughout the process, we establish the usePersistentState hook and managePersistentState functions via other functions. This approach, while seemingly elaborate, is perfect for reusability. When I want to include local storage on a new app, all I do is create a persistentState file, which is a few lines of code. All logic code remains hidden in a different package which is greatly beneficial in the case of a monorepo.

import { TemporaryStorage } from "@increaser/ui/state/TemporaryStorage"
import { LocalStorage } from "@increaser/ui/state/LocalStorage"
import { createPersistentStateHook } from "@increaser/ui/state/createPersistentStateHook"
import { createPersistentStateManager } from "@increaser/ui/state/createPersistentStateManager"

export enum PersistentStateKey {
  ThemePreference = "themePreference",
  // ...
}

export const persistentStorage =
  typeof window !== "undefined"
    ? new LocalStorage<PersistentStateKey>()
    : new TemporaryStorage<PersistentStateKey>()

export const usePersistentState =
  createPersistentStateHook<PersistentStateKey>(persistentStorage)

export const managePersistentState =
  createPersistentStateManager<PersistentStateKey>(persistentStorage)

Within the persistentState file, we begin by creating an enum with keys and continue with the initialization of the persistentStorage object. Depending upon whether local storage is avaliable or not, we use TemporaryStorage or LocalStorage. Both of these implement the same PersistentStorage interface. They include a way to get and set items and a method to subscribe to the changes of the value, else we won't be able to track the changes in our usePersistentState hook.

export type OnValueChangeListener<T> = (newValue: T, oldValue: T) => void

export interface PersistentStorage<T extends string> {
  getItem<V>(T: string): V | undefined
  setItem<V>(T: string, value: V): void
  addValueChangeListener<V>(key: T, listener: OnValueChangeListener<V>): void
  removeValueChangeListener<V>(key: T, listener: OnValueChangeListener<V>): void
}

LocalStorage & TemporaryStorage implementation

The getItem method retrieves the value from localStorage and parses it. We first handle null and undefined cases, then attempt to parse the value as JSON. If this fails, the value is returned as a string.

import { OnValueChangeListener, PersistentStorage } from "./PersistentStorage"

export class LocalStorage<T extends string> implements PersistentStorage<T> {
  listeners: Record<string, OnValueChangeListener<any>[]> = {}

  getItem<V>(key: T) {
    const item = localStorage.getItem(key)

    if (item === null) return undefined

    if (item === "null") return null as never as V
    if (item === "undefined") return undefined

    try {
      return JSON.parse(item) as V
    } catch {
      return item as never as V
    }
  }
  setItem<V>(key: T, value: V) {
    const oldValue = this.getItem(key)
    const newValue = JSON.stringify(value)
    if (oldValue === newValue) return

    if (value === undefined) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, newValue)
    }

    const listeners = this.listeners[key] || []

    listeners.forEach((listener) => {
      listener(value, oldValue)
    })
  }
  addValueChangeListener<V>(
    key: string,
    listener: OnValueChangeListener<V>
  ): void {
    if (!this.listeners[key]) {
      this.listeners[key] = []
    }

    this.listeners[key].push(listener)
  }
  removeValueChangeListener<T>(
    key: string,
    listener: OnValueChangeListener<T>
  ): void {
    this.listeners[key] = (this.listeners[key] || []).filter(
      (l) => l !== listener
    )
  }
}

In the setItem method, we verify whether the value has changed. If it has, we update the value in local storage and alert all listeners subscribed to that value's changes.

The addValueChangeListener and removeValueChangeListener methods simply add and remove listeners from the listeners object.

The TemporaryStorage follows the same logic, but uses a basic object to store values instead of local storage.

import { OnValueChangeListener, PersistentStorage } from "./PersistentStorage"

export class TemporaryStorage<T extends string>
  implements PersistentStorage<T>
{
  storage: Record<string, unknown> = {}
  listeners: Record<string, OnValueChangeListener<any>[]> = {}

  getItem<V>(key: T) {
    return this.storage[key] as V
  }
  setItem<V>(key: T, value: V) {
    const oldValue = this.getItem(key)
    if (oldValue === value) return

    if (value === undefined) {
      delete this.storage[key]
    } else {
      this.storage[key] = value
    }

    const listeners = this.listeners[key] || []

    listeners.forEach((listener) => {
      listener(value, oldValue)
    })
  }
  addValueChangeListener<V>(
    key: string,
    listener: OnValueChangeListener<V>
  ): void {
    if (!this.listeners[key]) {
      this.listeners[key] = []
    }

    this.listeners[key].push(listener)
  }
  removeValueChangeListener<T>(
    key: string,
    listener: OnValueChangeListener<T>
  ): void {
    this.listeners[key] = (this.listeners[key] || []).filter(
      (l) => l !== listener
    )
  }
}

createPersistentStateHook and createPersistentStateManager functions

The createPersistentStateHook function takes the storage object and returns the usePersistentState hook. The hook utilizes the useState hook to duplicate the value from local storage and trigger the component to rerender when the value changes. If there's no value in local storage, we'll use the initial value but will not update local storage with the initial value to avoid superfluous rerenders. We use the useEffect hook to update the state. By subscribing to local storage value changes, we can refresh the state whenever the value changes, and unsubscribe from the changes when the component is dismounted.

import { OnValueChangeListener } from "./PersistentStorage"
import { useCallback, useEffect, useState } from "react"
import { PersistentStorage } from "./PersistentStorage"

export function createPersistentStateHook<T extends string>(
  storage: PersistentStorage<T>
) {
  function usePersistentState<V>(key: T, initialValue: V) {
    const [value, setValue] = useState<V>(() => {
      const valueFromStorage = storage.getItem<V>(key)

      return valueFromStorage === undefined ? initialValue : valueFromStorage
    })

    useEffect(() => {
      const onValueChange: OnValueChangeListener<V> = (newValue) => {
        setValue(newValue)
      }

      storage.addValueChangeListener(key, onValueChange)

      return () => storage.removeValueChangeListener(key, onValueChange)
    }, [key])

    const setPersistentStorageValue = useCallback(
      (newValue: V) => {
        storage.setItem(key, newValue)
      },
      [key]
    )

    return [value, setPersistentStorageValue] as const
  }

  return usePersistentState
}

The createPersistentStateManager function is a small wrapper that returns an object with get and set methods, which read and write values to local storage for a particular key.

import { PersistentStorage } from "./PersistentStorage"

export function createPersistentStateManager<T extends string>(
  storage: PersistentStorage<T>
) {
  function managePersistentState<V>(key: T) {
    return {
      get: () => storage.getItem<V | undefined>(key),
      set: (value: V | undefined) => storage.setItem(key, value),
    }
  }

  return managePersistentState
}