일반적으로 다크 모드는 브라우저를 꺼도 설정이 계속 유지되도록 만든다. 이런 동작을 구현하려면 localStorage 등에 다크 모드 설정값을 저장해 놓아야 한다. 이번에는 localStorage의 값을 리액트 18에서 안전하게 읽고 쓸 수 있는 훅을 구현해 보자.
localStorage 에 저장된 값을 마치 리액트의 useState
처럼 훅 기반으로 읽고 쓸 수 있다면 편리할 것이다. usehooks-ts 라는 훅 기반 상태관리 라이브러리에서는 아래와 같은 형태로 useLocalStorage
훅을 제공하고 있다.
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 keep keep 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]
}
key
와 initialState
를 인자로 받는다. key
는 localStorage 에 값을 저장할 때 사용할 키이며, initialState
는 초기값이며 오류시에 반환할 값이기도 하다. 구체적인 동작은 아래에서 쪼개서 하나하나 살펴보자.
localStorage 는 getItem(key)
메소드를 호출했을 때 해당 key
에 대한 항목이 존재하면 string
형태로 반환하며, 없으면 null
을 반환한다.
해당 key
에 대한 항목이 존재한다면 사용자가 그 값을 한 번이라도 저장했다는 소리가 된다. 그러니까 초기값 대신 저장된 값을 반환해야 한다. 반면 null
이 반환되었을 경우 사용자가 값을 저장한 적이 없거나 삭제했다는 의미이기 때문에 초기값을 반환해야 한다. 따라서 어떤 key
에 대해 읽어진 값은 아래와 같이 쓸 수 있다.
const item = window.localStorage.getItem(key)
return item ? (parseJSON(item) as T) : initialValue
몇 가지 예외사항을 고려해 개선하면 다음과 같이 읽기 함수를 쓸 수 있다.
const readValue = useCallback((): T => {
// Prevent build error "window is undefined" but keep keep 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])
window
를 한 번 체크하는 이유는 SSR에서 window is undefined
오류를 막기 위해서이다. 또한 localStorage는 브라우저 api 이기 때문에 모종의 이유로 읽기 실패할 수도 있다. 이런 경우 초기값을 반환한다.
localStorage의 setItem(key, value)
메소드를 이용하면 값을 쓸 수 있다. 여기서 value
에는 반드시 string
만 전달해야 한다. 저장할 객체가 string
이 아닌 경우 일반적으로는 JSON.stringify
를 이용해 string
형태로 변환한다. 따라서 값을 쓰는 부분은 아래와 같이 쓸 수 있다.
window.localStorage.setItem(key, JSON.stringify(newValue))
이 훅이 아닌 다른 코드에서 localStorage를 같이 사용할 수 있다. 거기서 값을 업데이트할 경우, 이 훅에서는 알 방법이 없기 때문에 기존 값을 계속 들고 있게 된다. 그러면 UX 일관성이 깨질 수 있다.
이를 방지하기 위해 custom event를 사용할 수 있다. localStorage 의 변화가 있으면 custom event 를 발생시키도록 하고, 이를 수신하면서 값의 업데이트 여부를 알아내는 것이다. 구체적인 코드는 복잡하므로 usehooks-ts: useLocalStorage를 참고하기 바란다. useEventListener
라는 커스텀 훅을 이용, storage
의 업데이트 시 커스텀 이벤트 'local-storage'
를 발생시키고 여기에 대한 핸들러를 붙여 사용하고 있다.
// 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)
useEventListenter
를 두 번 사용했는데, 아래쪽 리스너에서 커스텀 이벤트 'local-storage'
에 대한 핸들러를 붙였다.
위쪽의 리스너는 브라우저 내장 이벤트 'storage'
를 처리하기 위한 것이다. 이 이벤트는 다른 창이나 탭으로 띄운 동일 사이트에서 sessionStorage 또는 localStorage 의 변화가 발생할 때 전달된다. 다른 창에서 다크모드를 활성화했을 때도 이를 통해 감지할 수 있다.
이 코드는 리액트 18에서 잘 작동할까? 공식 문서에서, 리액트 18 버전부터는 외부 저장소를 사용할 때 특별한 처리가 필요하다고 적고 있다. 위 코드를 리액트 18 에서 그대로 사용할 경우 tearing 이라 부르는 오류가 발생할 수 있다.
리액트 18에서는 concurrent rendering, 즉 동시 랜더링 기능을 통해 성능을 비약적으로 향상시킬 수 있다. 하지만 으레 동시성 프로그래밍이 그러하듯 파악하기 힘든 오류가 생길 수 있다.
대표적 사례는 useTransition
훅을 사용할 때의 tearing 문제다. 아래 영상은 버튼을 누르고 잠시 후 마우스의 x 좌표를 읽어 여러 번 출력하는 시나리오인데, 마우스의 위치는 하나이므로 한 가지 숫자만 출력되어야 하지만 행마다 다른 숫자가 출력된다. 이는 startTrasition
의 지연이 행마다 다르게 적용되어 각기 다른 시점의 마우스 좌표를 읽었기 때문이다.
이와 같이 하나의 참조를 각각 다르게 읽어서 UX 일관성을 해치는 경우를 tearing 이라 한다. localStrage를 사용하는 경우에도 동일한 문제가 생길 수 있는데, localStorage에 대한 읽기 작업이 useTransition
등을 사용하여 낮은 우선순위로 지정되었다면 tearing 문제가 생길 수 있다.
말하자면, 한 페이지의 어디서는 다크 모드가 적용되고, 어디서는 안 되는 문제가 생길 수도 있다.
react 18 공식 문서에서는 이런 문제를 방지하기 위해 리액트에서 직접 관리되지 않는 상태를 참조할 때는 useSyncExternalStore
훅을 사용하도록 권장하고 있다. 이 훅은 랜더링 시점에 값을 강제로 동기 참조하여 UX 일관성이 깨지지 않게 한다.
작성중입니다.
리액트 공식 문서
react working group
react conference 2021
usehooks-ts