useLocalStorage


Persist the state with local storage so that it remains after a page refresh. This can be useful for a dark theme. This hook is used in the same way as useState except that you must pass the storage key in the 1st parameter. If the window object is not present (as in SSR), useLocalStorage() will return the default value.

Side notes:

Related hooks:

Usage

import { useLocalStorage } from 'reactchemy'

// Usage
export default function Component() {
   const [isDarkTheme, setDarkTheme] = useLocalStorage('darkTheme', true)

   const toggleTheme = () => {
      setDarkTheme((prevValue: boolean) => !prevValue)
   }

   return (
    <button onClick={toggleTheme}>
      {`The current theme is ${isDarkTheme ? 'dark' : 'light'}`}
    </button>
   )
}

Hook

import type {
   Dispatch,
   SetStateAction,
} from 'react'
import {
   useCallback,
   useEffect,
   useState,
} from 'react'

import { useEventCallback, useEventListener } from 'reactchemy'

declare global {
   interface WindowEventMap {
      'local-storage': CustomEvent
   }
}

type SetValue<T> = Dispatch<SetStateAction<T>>

export function useLocalStorage<T>(
   key: string,
   initialValue: T,
): [T, SetValue<T>] {
   // Get from local storage then
   // parse stored json or return initialValue
   const readValue = useCallback((): T => {
      // Prevent build error "window is undefined" but keeps working
      if (typeof window === 'undefined')
         return initialValue

      try {
         const item = window.localStorage.getItem(key)
         return item ? (parseJSON(item) as T) : initialValue
      }
      catch (error) {
         console.warn(`Error reading localStorage key “${key}”:`, error)
         return initialValue
      }
   }, [initialValue, key])

   // State to store our value
   // Pass initial state function to useState so logic is only executed once
   const [storedValue, setStoredValue] = useState<T>(readValue)

   // Return a wrapped version of useState's setter function that ...
   // ... persists the new value to localStorage.
   const setValue: SetValue<T> = useEventCallback((value) => {
      // Prevent build error "window is undefined" but keeps working
      if (typeof window === 'undefined') {
         console.warn(
        `Tried setting localStorage key “${key}” even though environment is not a client`,
         )
      }

      try {
      // Allow value to be a function so we have the same API as useState
         const newValue = value instanceof Function ? value(storedValue) : value

         // Save to local storage
         window.localStorage.setItem(key, JSON.stringify(newValue))

         // Save state
         setStoredValue(newValue)

         // We dispatch a custom event so every useLocalStorage hook are notified
         window.dispatchEvent(new Event('local-storage'))
      }
      catch (error) {
         console.warn(`Error setting localStorage key “${key}”:`, error)
      }
   })

   useEffect(() => {
      setStoredValue(readValue())
      // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [])

   const handleStorageChange = useCallback(
      (event: StorageEvent | CustomEvent) => {
         if ((event as StorageEvent)?.key && (event as StorageEvent).key !== key)
            return

         setStoredValue(readValue())
      },
      [key, readValue],
   )

   // this only works for other documents, not the current one
   useEventListener('storage', handleStorageChange)

   // this is a custom event, triggered in writeValueToLocalStorage
   // See: useLocalStorage()
   useEventListener('local-storage', handleStorageChange)

   return [storedValue, setValue]
}

// A wrapper for "JSON.parse()"" to support "undefined" value
function parseJSON<T>(value: string | null): T | undefined {
   try {
      return value === 'undefined' ? undefined : JSON.parse(value ?? '')
   }
   catch {
      console.log('parsing error on', { value })
      return undefined
   }
}