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