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