Immer Jotai 와 zod 스토리지 사용하기

4 minute read
2024-07-27

영속적인 상태를 관리하기 위해 localStorage 를 사용할 때가 있다. 이때 상태관리 라이브러리와 동기화하는 작업이 중요하다. 또한 설계한 스키마에 맞는지 검증하는 작업도 필요하다.


상태관리에 있어 대표적으로 jotai 가 있으며, DX 가 좋은 immer 를 같이 사용할 수 있다.


스키마 검증을 간편하게 할 수 있으면서 타입 장점을 얻을 수 있는 zod 라이브러리를 같이 사용하면 영속적인 데이터를 효율적으로 관리할 수 있다.


jotai

우선 웹스토지를 사용한다. 그 중에 여기서는 localStorage 를 사용하려고 한다.


Web Storage
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

zod schema
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 와 함께 사용하여 검증 처리하는 방법은 관련해서는 공식문서를 확인하면 좋다.


나와 같은 경우 아래와 같이 작성했다.


impl web storage
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

spread update state
const [storage, setStorage] = useAtom(storageAtom)
 
<Button
  onClick={() => {
    setStorage((prev) => ({
      ...prev,
      prev.checked: !prev.checked,
    }))
  }}
>
  toggle
</Button>


위 코드처럼 atom 상태 변경 시, 불변한 객체로 변경해야한다. 근데, 객체의 depth 가 깊어질 수록 spread 문법이 많아지고 직관성이 떨어진다.


이를 위해 코드 작성은 mutable 하지만, 알아서 immutable 연산을 대신 해주는 imemr 를 사용할 수 있다.


적용하는 방법은 아주 간단하다. 그냥 함수로 감싸면 된다. 합성 함수 패턴으로 적용한다.


wrapping existed atom
import { withImmer } from 'jotai-immer'
 
export const storageAtom = withImmer(
  //  기존 storage atom
	atomWithStorage<StorageSchema>(STORAGE_KEY, createInitialValue(), {
    // ...
  })
)


그럼 코드를 아래와 같이 손쉽게 상태 변경 로직을 작성할 수 있다.


update state by immer
<Button
  onClick={() => {
    setStorage((prev) => {
      prev.checked = !prev.checked
      return prev
    })
  }}
>
  toggle
</Button>


자세한 사항은 공식문서 에서 확인할 수 있다.


#typescript