React의 경우 window는 해당 컴포넌트가 mount 될때부터 접근이 가능합니다.
그래서 단순 react의 코드에서 window.localstorage
와 같이 local storage를 접근하려하면 window is undefined
와 같은 에러가 발생하게 됩니다.
그렇다면 window
객체를 불러올 수 있는 mount 이후에 window를 접근하면 해결되겠죠?
그래서 React custom hook을 사용하여 local storage를 접근하는 방법을 많이 사용합니다.
처음에는 유지 보수를 위해 utils 폴더를 만들어 localstorage를 사용하는 코드들을 모아두고 사용하고 싶었습니다.
usehook에서 제공하는 useLocalStorage()
훅을 Next.js 환경에서 사용하려 하였습니다.
Next.js는 Server side rendering 또한 지원하는데,
local storage는 Client side에서만 사용이 가능해 문제가 생긴 것 이였죠
발생한 에러는 밑과 같았습니다.
Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching <div> in <div>.
See more info here: https://nextjs.org/docs/messages/react-hydration-error
해당 링크를 확인해보면, usehook에서 공개하는 useLocalStorage
custom hook이 있습니다.
이 hook을 가지고
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react'
import { useEventCallback, useEventListener } from 'usehooks-ts'
declare global {
interface WindowEventMap {
'local-storage': CustomEvent
}
}
type SetValue<T> = Dispatch<SetStateAction<T>>
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]
}
export default useLocalStorage
// 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
}
}
참고 링크 : https://usehooks-ts.com/react-hook/use-local-storage
local storage는 client side에서만 사용이 가능하니,
접근 하기 전에 client side인지, 즉 mounted 된 이후인지를 체크하면 되었습니다..
코드 하단부를 밑과 같이 수정해 문제를 해결할 수 있었습니다.
mount가 되었는지 state를 통해 관리하고, mount가 되는 시점인 useEffect 내부의 함수가 실행될 때 hasMounted
의 값을 true
로 변경합니다.
state가 변경이 되면 컴포넌트가 렌더링이 되고, local storage의 값을 화면에 렌더링 할 수 있습니다.
⚠️ hasMounted의 값을 ref로 관리하게 되면, hasMounted의 값이 바뀌어도 컴포넌트가 렌더링이 되지 않아 원하는 동작을 수행할 수 없습니다
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (hasMounted) {
return [storedValue, setValue] as const;
}
return [initialValue, setValue] as const;
import { useEffect, useState } from 'react';
/**
* @description 페이지 새로 고침을 통해 상태가 유지되도록 로컬 저장소에 동기화합니다.
*
* @param key 로컬 저장소에 저장될 키
* @param initialValue 초기 값
* @returns [storedValue, setValue] - 로컬 저장소에 저장된 값, 저장 함수
*/
function useLocalStorage<T>(key: string, initialValue: T) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.error(error);
}
};
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (hasMounted) {
return [storedValue, setValue] as const;
}
return [initialValue, setValue] as const;
}
export default useLocalStorage;