useLocalStorage.ts113 lines · main
| 1 | // Reference: https://usehooks.com/useLocalStorage/ |
| 2 | |
| 3 | import { useQuery, useQueryClient } from '@tanstack/react-query' |
| 4 | import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react' |
| 5 | |
| 6 | export function useLocalStorage<T>(key: string, initialValue: T) { |
| 7 | // State to store our value |
| 8 | // Pass initial state function to useState so logic is only executed once |
| 9 | const [storedValue, setStoredValue] = useState<T>(() => { |
| 10 | if (typeof window === 'undefined') { |
| 11 | return initialValue |
| 12 | } |
| 13 | |
| 14 | try { |
| 15 | // Get from local storage by key |
| 16 | const item = window.localStorage.getItem(key) |
| 17 | // Parse stored json or if none return initialValue |
| 18 | return item ? JSON.parse(item) : initialValue |
| 19 | } catch (error) { |
| 20 | // If error also return initialValue |
| 21 | console.log(error) |
| 22 | return initialValue |
| 23 | } |
| 24 | }) |
| 25 | |
| 26 | // Return a wrapped version of useState's setter function that ... |
| 27 | // ... persists the new value to localStorage. |
| 28 | const setValue = useCallback( |
| 29 | (value: T | ((val: T) => T)) => { |
| 30 | try { |
| 31 | // Allow value to be a function so we have same API as useState |
| 32 | const valueToStore = value instanceof Function ? value(storedValue) : value |
| 33 | // Save state |
| 34 | setStoredValue(valueToStore) |
| 35 | // Save to local storage |
| 36 | if (typeof window !== 'undefined') { |
| 37 | window.localStorage.setItem(key, JSON.stringify(valueToStore)) |
| 38 | } |
| 39 | } catch (error) { |
| 40 | // A more advanced implementation would handle the error case |
| 41 | console.log(error) |
| 42 | } |
| 43 | }, |
| 44 | [key, storedValue] |
| 45 | ) |
| 46 | |
| 47 | return [storedValue, setValue] as const |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Hook to load/store values from local storage with an API similar |
| 52 | * to `useState()`. |
| 53 | * |
| 54 | * Differs from `useLocalStorage()` in that it uses `react-query` to |
| 55 | * invalidate stale values across hooks with the same key. |
| 56 | */ |
| 57 | export function useLocalStorageQuery<T>(key: string, initialValue: T) { |
| 58 | const queryClient = useQueryClient() |
| 59 | const queryKey = useMemo(() => ['localStorage', key], [key]) |
| 60 | |
| 61 | const { |
| 62 | error, |
| 63 | data: storedValue = initialValue, |
| 64 | isSuccess, |
| 65 | isLoading, |
| 66 | isError, |
| 67 | } = useQuery({ |
| 68 | queryKey, |
| 69 | queryFn: () => { |
| 70 | if (typeof window === 'undefined') { |
| 71 | return initialValue |
| 72 | } |
| 73 | |
| 74 | const item = window.localStorage.getItem(key) |
| 75 | |
| 76 | if (!item) { |
| 77 | return initialValue |
| 78 | } |
| 79 | |
| 80 | return JSON.parse(item) as T |
| 81 | }, |
| 82 | }) |
| 83 | |
| 84 | const setValue: Dispatch<SetStateAction<T>> = useCallback( |
| 85 | (value) => { |
| 86 | const currentValue = queryClient.getQueryData<T>(queryKey) ?? initialValue |
| 87 | const valueToStore = value instanceof Function ? value(currentValue) : value |
| 88 | |
| 89 | // Bail out when the value is unchanged (matches useState semantics). |
| 90 | // Without this, no-op updates from consumers — like a pruning effect |
| 91 | // whose updater returns `current` unchanged — still write to |
| 92 | // localStorage and invalidate the query, which churns subscribers and |
| 93 | // can cascade into "Maximum update depth exceeded" when two consumers |
| 94 | // of the same key are mounted together. |
| 95 | if (Object.is(valueToStore, currentValue)) return |
| 96 | |
| 97 | if (typeof window !== 'undefined') { |
| 98 | window.localStorage.setItem(key, JSON.stringify(valueToStore)) |
| 99 | } |
| 100 | |
| 101 | queryClient.setQueryData(queryKey, valueToStore) |
| 102 | queryClient.invalidateQueries({ queryKey }) |
| 103 | }, |
| 104 | // initialValue is intentionally excluded: the function body reads the |
| 105 | // current value via queryClient.getQueryData so it doesn't close over |
| 106 | // initialValue reactively — including it would cause a new function |
| 107 | // reference every render when callers pass an inline literal (e.g. []). |
| 108 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 109 | [key, queryKey, queryClient] |
| 110 | ) |
| 111 | |
| 112 | return [storedValue, setValue, { isSuccess, isLoading, isError, error }] as const |
| 113 | } |