Storage
atomWithStorage
Ref: https://github.com/pmndrs/jotai/pull/394
import { useAtom } from 'jotai'import { atomWithStorage } from 'jotai/utils'const darkModeAtom = atomWithStorage('darkMode', false)const Page = () => {const [darkMode, setDarkMode] = useAtom(darkModeAtom)return (<><h1>Welcome to {darkMode ? 'dark' : 'light'} mode!</h1><button onClick={() => setDarkMode(!darkMode)}>toggle theme</button></>)}
atomWithStorage
함수는 localStorage
나 sessionStorage
(React의 경우) 또는 AsyncStorage
(React Native의 경우)에 값이 저장되는 아톰을 생성합니다.
파라미터
key (필수): localStorage, sessionStorage, 또는 AsyncStorage와 상태를 동기화할 때 사용할 고유 문자열
initialValue (필수): 아톰의 초기값
storage (선택): 다음 메서드를 포함하는 객체:
- getItem(key, initialValue) (필수): 스토리지에서 아이템을 읽거나,
initialValue
로 대체 - setItem(key, value) (필수): 스토리지에 아이템 저장
- removeItem(key) (필수): 스토리지에서 아이템 삭제
- subscribe(key, callback, initialValue) (선택): 외부 스토리지 업데이트를 구독하는 메서드
options (선택): 다음 속성을 포함하는 객체:
- getOnInit (선택, 기본값 false): 초기화 시 스토리지에서 아이템을 가져올지 여부를 나타내는 불리언 값.
getOnInit
가 설정되지 않았거나false
인 SPA에서는 초기화 시 항상 저장된 값 대신 초기값을 가져옵니다. 저장된 값을 사용하려면getOnInit
를true
로 설정하세요.
지정되지 않은 경우, 기본 스토리지 구현은 저장/검색을 위해 localStorage
를 사용하고, 직렬화/역직렬화를 위해 JSON.stringify()
/JSON.parse()
를 사용하며, 크로스 탭 동기화를 위해 storage
이벤트를 구독합니다.
createJSONStorage
유틸리티
storage
옵션을 위해 JSON.stringify()
와 JSON.parse()
를 사용하는 커스텀 스토리지 구현을 만들기 위해 createJSONStorage
유틸리티가 제공됩니다.
사용법:
const storage = createJSONStorage(// getStringStorage() => localStorage, // 또는 sessionStorage, asyncStorage 등// 옵션 (선택 사항){reviver, // JSON.parse를 위한 선택적 reviver 옵션replacer, // JSON.stringify를 위한 선택적 replacer 옵션},)
참고: JSON.parse
는 타입 안전하지 않습니다. 모든 타입을 허용할 수 없다면, 프로덕션 앱에서는 어떤 형태의 검증이 필요할 수 있습니다.
서버 사이드 렌더링
저장된 아톰 값(예: className
또는 style
prop)에 의존하는 JSX 마크업은 서버에서 렌더링될 때 initialValue
를 사용합니다(서버에서는 localStorage
와 sessionStorage
를 사용할 수 없기 때문입니다).
이는 사용자가 initialValue
와 다른 storedValue
를 가지고 있는 경우, 사용자의 브라우저에 HTML로 제공된 내용과 React가 재수화(rehydration) 과정에서 기대하는 내용 사이에 불일치가 발생할 수 있음을 의미합니다.
이 문제를 해결하기 위한 권장 방법은 storedValue
에 의존하는 콘텐츠를 커스텀 <ClientOnly>
래퍼로 감싸서 클라이언트 사이드에서만 렌더링하도록 하는 것입니다. 이 래퍼는 재수화가 완료된 후에만 렌더링됩니다. 기술적으로 다른 해결책도 가능하지만, initialValue
가 storedValue
로 교체되는 동안 잠깐의 "깜빡임"이 발생할 수 있어 사용자 경험에 좋지 않은 영향을 미칠 수 있으므로 이 방법을 권장합니다.
스토리지에서 아이템 삭제하기
스토리지에서 아이템을 삭제하고 싶은 경우, atomWithStorage
로 생성된 아톰은 쓰기 작업 시 RESET
심볼을 허용합니다.
사용법은 아래 예제를 참고하세요:
import { useAtom } from 'jotai'import { atomWithStorage, RESET } from 'jotai/utils'const textAtom = atomWithStorage('text', 'hello')const TextBox = () => {const [text, setText] = useAtom(textAtom)return (<><input value={text} onChange={(e) => setText(e.target.value)} /><button onClick={() => setText(RESET)}>Reset (to 'hello')</button></>)}
필요한 경우, 이전 값을 기반으로 조건부 리셋을 수행할 수도 있습니다.
이 기능은 이전 값이 특정 조건을 만족할 때 localStorage의 키를 지우고 싶을 때 특히 유용합니다.
아래 예제는 이전 값이 true
일 때마다 visible
키를 지우는 사용법을 보여줍니다.
import { useAtom } from 'jotai'import { atomWithStorage, RESET } from 'jotai/utils'const isVisibleAtom = atomWithStorage('visible', false)const TextBox = () => {const [isVisible, setIsVisible] = useAtom(isVisibleAtom)return (<>{ isVisible && <h1>Header is visible!</h1> }<button onClick={() => setIsVisible((prev) => prev ? RESET : true))}>Toggle visible</button></>)}
React-Native 구현
getItem
, setItem
, removeItem
을 구현한 어떤 라이브러리든 사용할 수 있습니다. 예를 들어, 커뮤니티에서 제공하는 표준 AsyncStorage를 사용한다고 가정해 보겠습니다.
import { atomWithStorage, createJSONStorage } from 'jotai/utils'import AsyncStorage from '@react-native-async-storage/async-storage'const storage = createJSONStorage(() => AsyncStorage)const content = {} // JSON으로 직렬화 가능한 어떤 값이든 가능const storedAtom = atomWithStorage('stored-key', content, storage)
AsyncStorage와 함께 사용하는 노트 (v2.2.0 이후)
AsyncStorage(또는 다른 비동기 스토리지)를 사용할 때, atom 값은 비동기적으로 처리됩니다.
현재 값을 참조하여 atom을 업데이트할 때는 await
를 사용해야 합니다.
const countAtom = atomWithStorage('count-key', 0, anyAsyncStorage)const Component = () => {const [count, setCount] = useAtom(countAtom)const increment = () => {setCount(async (promiseOrValue) => (await promiseOrValue) + 1)}// ...}
저장된 값 검증하기
스토리지 아톰에 런타임 검증을 추가하려면, 스토리지의 커스텀 구현을 만들어야 합니다.
아래는 Zod를 사용하여 localStorage
에 저장된 값을 검증하고, 크로스 탭 동기화를 수행하는 예제입니다.
import { atomWithStorage } from 'jotai/utils'import { z } from 'zod'const myNumberSchema = z.number().int().nonnegative()const storedNumberAtom = atomWithStorage('my-number', 0, {getItem(key, initialValue) {const storedValue = localStorage.getItem(key)try {return myNumberSchema.parse(JSON.parse(storedValue ?? ''))} catch {return initialValue}},setItem(key, value) {localStorage.setItem(key, JSON.stringify(value))},removeItem(key) {localStorage.removeItem(key)},subscribe(key, callback, initialValue) {if (typeof window === 'undefined' ||typeof window.addEventListener === 'undefined') {return}window.addEventListener('storage', (e) => {if (e.storageArea === localStorage && e.key === key) {let newValuetry {newValue = myNumberSchema.parse(JSON.parse(e.newValue ?? ''))} catch {newValue = initialValue}callback(newValue)}})},})
또한, 일부 경우를 단순화하기 위해 새로운 유틸리티인 unstable_withStorageValidator
를 사용할 수 있습니다.
위의 경우는 다음과 같이 변경됩니다:
import {atomWithStorage,createJSONStorage,unstable_withStorageValidator as withStorageValidator,} from 'jotai/utils'import { z } from 'zod'const myNumberSchema = z.number().int().nonnegative()const isMyNumber = (v) => myNumberSchema.safeParse(v).successconst storedNumberAtom = atomWithStorage('my-number',0,withStorageValidator(isMyNumber)(createJSONStorage()),)