Skip to content

Commit

Permalink
Refactor: external store in useLocalStorage (#1014)
Browse files Browse the repository at this point in the history
Refactor: external store in useLocalStorage
  • Loading branch information
katspaugh authored Nov 1, 2022
1 parent b2dfe3b commit a2b0ea8
Show file tree
Hide file tree
Showing 3 changed files with 34 additions and 28 deletions.
5 changes: 3 additions & 2 deletions src/services/ExternalStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react'

type Listener = () => void
type Undefinable<T> = T | undefined

// Singleton with getter/setter whose hook triggers a re-render
class ExternalStore<T extends unknown> {
Expand All @@ -15,9 +16,9 @@ class ExternalStore<T extends unknown> {
return this.store
}

public readonly setStore = (value: T): void => {
public readonly setStore = (value: Undefinable<T> | ((oldVal: Undefinable<T>) => Undefinable<T>)): void => {
if (value !== this.store) {
this.store = value
this.store = value instanceof Function ? value(this.store) : value
this.listeners.forEach((listener) => listener())
}
}
Expand Down
11 changes: 7 additions & 4 deletions src/services/local-storage/__tests__/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ describe('useLocalStorage', () => {
})

it('should set the value', () => {
const { result } = renderHook(() => useLocalStorage('test-key'))
const key = Math.random().toString(32)
const { result } = renderHook(() => useLocalStorage(key))
const [value, setValue] = result.current

expect(value).toBe(undefined)
Expand All @@ -27,7 +28,8 @@ describe('useLocalStorage', () => {
})

it('should set the value using a callback', () => {
const { result } = renderHook(() => useLocalStorage<string>('test-key'))
const key = Math.random().toString(32)
const { result } = renderHook(() => useLocalStorage<string>(key))
const [value, setValue] = result.current

expect(value).toBe(undefined)
Expand All @@ -46,9 +48,10 @@ describe('useLocalStorage', () => {
})

it('should read from LS on initial call', () => {
local.setItem('test-key', 'ls')
const key = Math.random().toString(32)
local.setItem(key, 'ls')

const { result } = renderHook(() => useLocalStorage('test-key'))
const { result } = renderHook(() => useLocalStorage(key))

expect(result.current[0]).toBe('ls')
})
Expand Down
46 changes: 24 additions & 22 deletions src/services/local-storage/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect } from 'react'
import ExternalStore from '../ExternalStore'
import local from './local'

// The setter accepts T or a function that takes the old value and returns T
Expand All @@ -7,61 +8,62 @@ type Undefinable<T> = T | undefined

type Setter<T> = (val: T | ((prevVal: Undefinable<T>) => Undefinable<T>)) => void

// External stores for each localStorage key which act as a shared cache for LS
const externalStores: Record<string, ExternalStore<any>> = {}

const useLocalStorage = <T>(key: string): [Undefinable<T>, Setter<T>] => {
const [cache, setCache] = useState<Undefinable<T>>()
if (!externalStores[key]) {
externalStores[key] = new ExternalStore<T>()
}
const { getStore, setStore, useStore } = externalStores[key] as ExternalStore<T>

// This is the setter that will be returned
// It will update the local storage and cache
const setNewValue = useCallback<Setter<T>>(
(value) => {
setCache((oldValue) => {
setStore((oldValue) => {
const newValue = value instanceof Function ? value(oldValue) : value

if (newValue !== oldValue) {
local.setItem(key, newValue)

// Dispatch a fake storage event within the current browser tab
// The real storage event is dispatched only in other tabs
window.dispatchEvent(
new StorageEvent('storage', {
key: local.getPrefixedKey(key),
}),
)
}

return newValue
})
},
[key],
[key, setStore],
)

// Subscribe to changes in local storage and update the cache
// This will work across tabs
// Set the initial value from LS on mount
useEffect(() => {
const syncCache = () => {
if (getStore() === undefined) {
const lsValue = local.getItem<T>(key)
if (lsValue !== null) {
setCache(lsValue)
setStore(lsValue)
}
}
}, [key, getStore, setStore])

// Subscribe to changes in local storage and update the cache
// This will work across tabs
useEffect(() => {
const onStorageEvent = (event: StorageEvent) => {
if (event.key === local.getPrefixedKey(key)) {
syncCache()
const lsValue = local.getItem<T>(key)
if (lsValue !== null && lsValue !== getStore()) {
setStore(lsValue)
}
}
}

// Set the initial value
syncCache()

window.addEventListener('storage', onStorageEvent)

return () => {
window.removeEventListener('storage', onStorageEvent)
}
}, [key])
}, [key, getStore, setStore])

return [cache, setNewValue]
return [useStore(), setNewValue]
}

export default useLocalStorage

0 comments on commit a2b0ea8

Please sign in to comment.