Utilizing Local Storage in React Components: A Comprehensive Guide
Tags: react, localstorage, persistentstate, programming
Published: 2023-09-14 04:58:13

✨ [**Watch on YouTube**](https://youtu.be/_90rzlGy0SM)  | 🐙 [GitHub](https://github.com/radzionc/reactkit/blob/main/app/state/persistentState.ts)

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.

```tsx
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:

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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
}
```