I have a productivity app, and usually, I store things that can work without a back-end in the local storage. Here we have a hook that provides a state for a widget that plays focus sounds. It stores everything in the local storage, and I call it usePersistentStorage
. It receives a key for local storage and an optional initial value.
const focusSoundsInitialState: FocusSoundsState = {
isEnabled: false,
mode: "compilation",
compilationConfiguration: defaultFocusSoundsComilationConfiguration,
configuration: {
fire: 0.8,
night: 0.2,
seaside: 0.2,
},
}
// inside of the component
const [state, setState] = usePersistentStorageValue(
"focus-sounds",
focusSoundsInitialState
)
In the hook, we have useState
with initializer function anduseEffect
that listens for the state change and updates localStorage. In the initializer, we start with taking value from local storage. Then we check if it's an object and return either value from storage or the initial value.
import { useEffect, useState } from "react"
import { persistentStorage } from "./persistentStorage"
export function usePersistentStorageValue<T>(key: string, initialValue?: T) {
const [value, setValue] = useState<T>(() => {
const valueFromStorage = persistentStorage.getItem(key)
if (
typeof initialValue === "object" &&
!Array.isArray(initialValue) &&
initialValue !== null
) {
return {
...initialValue,
...valueFromStorage,
}
}
return valueFromStorage || initialValue
})
useEffect(() => {
persistentStorage.setItem(key, value)
}, [key, value])
return [value, setValue] as const
}
There might be a situation when we want to use something different than localStorage, so we have an abstraction of persistent storage. It has two methods, one to get a value and another to set.
interface PersistentStorage {
getItem(key: string): string | null
setItem(key: string, value: any): void
}
class LocalStorage implements PersistentStorage {
getItem(key: string) {
const item = localStorage.getItem(key)
if (item === null) return undefined
if (item === "null") return null
if (item === "undefined") return undefined
try {
return JSON.parse(item)
} catch {}
return item
}
setItem(key: string, value: any) {
if (value === undefined) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(value))
}
}
}
class MockStorage implements PersistentStorage {
getItem() {
return null
}
setItem() {}
}
export const persistentStorage = window?.localStorage
? new LocalStorage()
: new MockStorage()
If there's no local storage in the window, we can provide a fallback, but I don't worry about that. In the getItem, we have fancy checks. Sometimes we need to distinguish null from undefined. In the end, we return a parsed result. There could be something wrong with the format, so we wrap it with try-catch. If we want to change the format of the stored value, we can migrate by changing the key. One approach would be to update a date postfix of the key every time we want to migrate.