How to Store React State in Local Storage

main

Let's see how to have React state in local storage.

We can use react-query for managing the state coming from the server and React Context for the app state. But there are also things and settings we don't want to send to the server, and that's where local storage is handy.

focus

For example, on the focus page of my app, I have notification settings, and to store those values, I utilize the usePersistentValue hook. It takes the key as a first argument and the initial value as the second argument.

const [
  hasTimerSoundNotification,
  setHasTimerSoundNotification,
] = usePersistentStorageValue<boolean>(
  PersistentStorageKey.HasTimerSoundNotification,
  true
)

To make the component using this hook rerender on value change, we rely on the useState hook. To initialize the state, we try to get the value from local storage, and if it's not defined, we set it to the initial value.

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

import { PersistentStorageKey, persistentStorage } from "./persistentStorage"

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

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

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

    persistentStorage.addValueChangeListener(key, onValueChange)

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

  const setPersistentStorageValue = useCallback(
    (newValue: T) => {
      if (newValue !== value) {
        persistentStorage.setItem(key, newValue)
      }
    },
    [key, value]
  )

  return [value, setPersistentStorageValue] as const
}

There are situations where more than one active component uses the same local storage value. To keep them up-to-date, we want to add event listeners to the persistent storage. Once there is an update, the onValueChange callback will update the state. When the consumer of this hook changes the value, we will update the value in the local storage, and the event listener will sync it up with the state.

import { LocalStorage } from "lib/state/PersistentStorage"

export enum PersistentStorageKey {
  OnboardedToBreak = "onboarded-to-break",
  AuthToken = "token",
  AuthTokenExpirationTime = "tokenExpirationTime",
  FocusDuration = "focusDuration",
  HasTimerAutoFinish = "hasTimerAutoFinish",
  HasTimerSoundNotification = "hasTimerSoundNotification",
  HasTimerBrowserNotification = "hasTimerBrowserNotification",
  HasBreakBrowserNotification = "breakBrowserNotification",
  HasBreakSoundNotification = "breakSoundNotification",
  BreakAutomaticDuration = "breakAutomaticDuration",
  HasBreakSoundRemindNotification = "hasBreakSoundRemindNotification",
  HasBreakAutomaticBreak = "hasBreakAutomaticBreak",
  UnsyncedSets = "unsyncedSets",
  LastPromptToKeepWorkingWasAt = "lastPromptToKeepWorkingWasAt",
  HadFocusOnboarding = "hadFocusOnboarding",
  FocusSounds = "focus-sounds",
  SidebarInstallPromptWasRejectedAt = "sidebarInstallPromptWasRejectedAt",
  SelectedSleepAdvice = "selectedSleepAdvice",
}

export const persistentStorage = new LocalStorage<PersistentStorageKey>()

My app stores quite a few things in local storage. I prefer using enum for keys so that when we want to migrate value format, all we need to do is to update the enum's value string representation. We'll pass this enum as a generic type to the LocalStorage class so that every time we interact with keys, it would allow only PersistantStorageKey type.

LocalStorage is an implementation of the PersistentStorage interface. In case we want to move our app to a platform that doesn't have local storage, all we'll need to do is to implement the given interface.

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
}

Even so, the user can open developer tools and mess localStorage we don't handle that. That's why we have type conversion using "as never" two times in the getItem method. If your app stores a complex structure, you can also pass serialize method instead of using JSON.parse, but my apps never needed anything like that.

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, JSON.stringify(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
    )
  }
}

The setItem method doesn't only update local storage but also triggers the update event. To do that, we get every listener for a given key and pass old and new values to the listener. To prevent unnecessary updates, we check that the new value is not the same as the old one at the beginning of the method.

Finally, we have one method to add a listener and another to remove it.