2026-09-10: 설명과 코드 예시를 보완했습니다. 아래 예시는 글의 설계 의도를 전달하기 위한 것이며, 원 프로젝트에 반영된 변경 내역과는 구분합니다.
설정값 하나를 localStorage에 저장하는 일은 간단하다. 같은 값을 여러 컴포넌트가 보여주고, 다른 탭에서도 바꿀 수 있게 되면 저장 이후의 일이 남는다. 어디에 변경을 알리고 무엇을 다시 읽을 것인가. 이 흐름을 묶고 싶어 훅을 만들었다.
먼저 짚을 동작이 있다. 브라우저의 storage 이벤트는 변경을 일으킨 창 자체에는 발생하지 않는다. 같은 출처의 다른 탭에서 일어난 localStorage 변경을 받는 것과, 현재 창의 다른 컴포넌트에 변경을 알리는 것은 따로 처리해야 한다. MDN: storage 이벤트
이전 예시의 상태 복제와 지연 쓰기를 다시 검토하며, 저장소의 현재 문자열을 구독하는 형태로 정리했다. 아래는 글을 위한 수정 예시다.
구현 범위
useSyncExternalStore로 원시 문자열 스냅샷을 구독하고, JSON 파싱은 그 다음에 한다. 스냅샷을 읽을 때마다 새 객체를 만들지 않기 위해서다. 서버 렌더링과 최초 hydration에서는 동일한 기본값을 사용한다. React: useSyncExternalStore
import { useCallback, useMemo, useSyncExternalStore } from 'react';
const CHANGE_EVENT = 'app:local-storage-change';
const getServerSnapshot = () => null;
function readStorage(key: string): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
export function useStorage<T>(
key: string,
initialValue: T,
decode: (input: unknown) => T | undefined,
) {
const getSnapshot = useCallback(() => readStorage(key), [key]);
const subscribe = useCallback((notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
try {
if (event.storageArea !== window.localStorage) return;
} catch {
return;
}
if (event.key === key || event.key === null) notify();
};
const handleLocal = (event: Event) => {
if (event instanceof CustomEvent && event.detail === key) notify();
};
window.addEventListener('storage', handleStorage);
window.addEventListener(CHANGE_EVENT, handleLocal);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(CHANGE_EVENT, handleLocal);
};
}, [key]);
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const value = useMemo(() => {
if (raw === null) return initialValue;
try {
const decoded = decode(JSON.parse(raw));
return decoded === undefined ? initialValue : decoded;
} catch {
return initialValue;
}
}, [raw, decode, initialValue]);
const setValue = useCallback((next: T | undefined): boolean => {
if (typeof window === 'undefined') return false;
try {
if (next === undefined) {
window.localStorage.removeItem(key);
} else {
const encoded = JSON.stringify(next);
if (encoded === undefined) return false;
window.localStorage.setItem(key, encoded);
}
window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: key }));
return true;
} catch {
return false;
}
}, [key]);
return [value, setValue] as const;
}
사용하기
브라우저에 저장된 JSON이 타입 매개변수대로 생겼다고 믿을 수는 없다. 그래서 사용처에서 읽은 값을 확인하는 함수도 함께 전달한다.
const decodeName = (input: unknown) =>
typeof input === 'string' ? input : undefined;
function NameSetting() {
const [name, setName] = useStorage('my-name', 'Anonymous', decodeName);
return (
<button type="button" onClick={() => {
if (!setName('Alice')) window.alert('이름을 저장하지 못했습니다.');
}}>
현재 이름: {name}
</button>
);
}
기본값, 저장, 알림의 순서
기본값은 저장소에 자동으로 쓰지 않는다. 값이 없거나 파싱·검증에 실패하면 화면에서 기본값을 사용한다. SSR에서는 서버와 클라이언트가 같은 initialValue를 전달해야 하고, hydration 이후 저장값으로 바뀔 수 있다.
이 구현은 쓰기를 즉시 수행한다. 실제 저장 전에 화면에만 성공한 값을 알리던 순서 문제를 피하고, 실패 여부를 호출자에게 반환하기 위해서다. 쓰기 지연이 필요한 입력창이라면 입력 중인 상태와 저장된 상태를 구분해서 설계하는 편이 명확하다.
현재 창의 동기화는 이 훅의 setValue를 통해 쓴다는 규칙에 의존한다. 다른 코드가 현재 창에서 localStorage.setItem을 직접 호출하면 커스텀 이벤트를 발생시키지 않으므로 즉시 통지되지 않는다. 다른 탭의 변경과 clear()는 브라우저 이벤트로 확인하며, 탭의 포커스 여부로 걸러내지 않는다.
여러 탭에서 동시에 쓰면 나중에 저장된 값이 남는다. 원자적인 갱신이나 충돌 해결은 이 훅의 범위를 벗어난다. JSON으로 저장할 수 있는 간단한 설정값을 읽고 쓰면서, 같은 창과 다른 탭의 화면에 변경을 알리는 데까지 사용한다.
Assisted by AI
© uiwwsw · 본인 소유의 글·이미지에 대한 무단 복제·재게시를 허용하지 않습니다. 개인 열람·원문 링크 공유·검색 색인과 제한적인 미리보기는 허용합니다. 법령상 이용 예외, 기존 이용 허락, 코드·제삼자 자료의 기존 라이선스는 유지합니다. 콘텐츠 이용 안내