JotaiJotai

상태
React를 위한 기본적이고 유연한 상태 관리

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 함수는 localStoragesessionStorage(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에서는 초기화 시 항상 저장된 값 대신 초기값을 가져옵니다. 저장된 값을 사용하려면 getOnInittrue로 설정하세요.

지정되지 않은 경우, 기본 스토리지 구현은 저장/검색을 위해 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를 사용합니다(서버에서는 localStoragesessionStorage를 사용할 수 없기 때문입니다).

이는 사용자가 initialValue와 다른 storedValue를 가지고 있는 경우, 사용자의 브라우저에 HTML로 제공된 내용과 React가 재수화(rehydration) 과정에서 기대하는 내용 사이에 불일치가 발생할 수 있음을 의미합니다.

이 문제를 해결하기 위한 권장 방법은 storedValue에 의존하는 콘텐츠를 커스텀 <ClientOnly> 래퍼로 감싸서 클라이언트 사이드에서만 렌더링하도록 하는 것입니다. 이 래퍼는 재수화가 완료된 후에만 렌더링됩니다. 기술적으로 다른 해결책도 가능하지만, initialValuestoredValue로 교체되는 동안 잠깐의 "깜빡임"이 발생할 수 있어 사용자 경험에 좋지 않은 영향을 미칠 수 있으므로 이 방법을 권장합니다.

스토리지에서 아이템 삭제하기

스토리지에서 아이템을 삭제하고 싶은 경우, 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 newValue
try {
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).success
const storedNumberAtom = atomWithStorage(
'my-number',
0,
withStorageValidator(isMyNumber)(createJSONStorage()),
)