Immer Jotai 와 zod 스토리지 사용하기
영속적인 상태를 관리하기 위해 localStorage 를 사용할 때가 있다. 이때 상태관리 라이브러리와 동기화하는 작업이 중요하다. 또한 설계한 스키마에 맞는지 검증하는 작업도 필요하다.
상태관리에 있어 대표적으로 jotai 가 있으며, DX 가 좋은 immer 를 같이 사용할 수 있다.
스키마 검증을 간편하게 할 수 있으면서 타입 장점을 얻을 수 있는 zod 라이브러리를 같이 사용하면 영속적인 데이터를 효율적으로 관리할 수 있다.
jotai
우선 웹스토지를 사용한다. 그 중에 여기서는 localStorage 를 사용하려고 한다.
const targetStorage = localStorage
// ...
targetStorage.getItem(key)위와 같이 작성한 이유는 나중에 session 스토리지로 바뀌는 경우, 참조만 바꾸면 바로 변경 가능하다. 인터페이스가 동일하기 때문에 문제가 없다.
atom Storage
atomStorage 를 만들기 위해선 필수적인 인자들이 있다.
import { atomWithStorage } from 'jotai/utils'
const STORAGE_KEY = 'storage-key' as const
const value = {}
useAtomStorage(key, value)더 자세한 사항은 공식 문서 에서 확인 가능하다.
웹 스토리지를 한 번이라도 사용했다면 키/값은 당연히 필요하다는 건 알 수 있다. 하지만, 사용하다보면 타입 부분에서 아쉬움이 많이 남는다.
이를 위해 zod 스키마 기반 패키지를 사용하면 개선할 수 있다.
zod
export const storageSchema = z.object({
date: z
.string()
.default(() => new Date().toISOString()),
checked: z
.boolean()
.optional()
.default(false),
})
export type StorageSchema = z.infer<typeof storageSchema>zod 에서는 위와 같이 스키마를 선언한다. 그리고 **기본값**이 필요한 경우가 많기 때문에 아래와 같이 헬퍼 함수를 작성할 수 있다.
const createInitialValue = () => {
const parsed = storageSchema.parse({})
return parsed
}다만, storageSchema 에 대한 결합도가 높기 때문에 개선할 여지가 아직 많아 보인다. (개선 필요!)
이젠 인터페이스를 구현 해야할 차례다.
WebStorage 인터페이스 구현
atomWithStorage<StorageSchema>(STORAGE_KEY, createInitialValue(), {
// TODO:
// - getItem
// - setItem
// - removeItem
// (optional)
// - subscribe
})필수적으로 구현해야할 함수는 get, set, remove 가 있다. zod 와 함께 사용하여 검증 처리하는 방법은 관련해서는 공식문서를 확인하면 좋다.
나와 같은 경우 아래와 같이 작성했다.
getItem(key, initialValue) {
const existedValue = targetStorage.getItem(key)
try {
return serializeValueBySchema(existedValue)
} catch {
return initialValue
}
},
setItem(key, value) {
targetStorage.setItem(key, JSON.stringify(value))
},
removeItem(key) {
targetStorage.removeItem(key)
},아까 localStorage 보단 참조하는 객체를 사용했기 때문에 만약 sessionStorage 를 사용한다면 금방 마이그레이션 가능하다.
위에서 사용한 유틸함수는 아래와 같이 작성했다.
function serializeValueBySchema(value: string | null) {
return storageSchema.parse(JSON.parse(value ?? ''))
}이 함수도 schema 에 결합되어 있기 때문에 나중에 class 로 묶어서 관심사를 하나로 모아두거나 제네릭으로 스키마를 인자로 받도록 하면 좋을 듯 싶다.
immer
const [storage, setStorage] = useAtom(storageAtom)
<Button
onClick={() => {
setStorage((prev) => ({
...prev,
prev.checked: !prev.checked,
}))
}}
>
toggle
</Button>위 코드처럼 atom 상태 변경 시, 불변한 객체로 변경해야한다. 근데, 객체의 depth 가 깊어질 수록 spread 문법이 많아지고 직관성이 떨어진다.
이를 위해 코드 작성은 mutable 하지만, 알아서 immutable 연산을 대신 해주는 imemr 를 사용할 수 있다.
적용하는 방법은 아주 간단하다. 그냥 함수로 감싸면 된다. 합성 함수 패턴으로 적용한다.
import { withImmer } from 'jotai-immer'
export const storageAtom = withImmer(
// 기존 storage atom
atomWithStorage<StorageSchema>(STORAGE_KEY, createInitialValue(), {
// ...
})
)그럼 코드를 아래와 같이 손쉽게 상태 변경 로직을 작성할 수 있다.
<Button
onClick={() => {
setStorage((prev) => {
prev.checked = !prev.checked
return prev
})
}}
>
toggle
</Button>자세한 사항은 공식문서 에서 확인할 수 있다.