Jotai 공식 문서를 통해 사용법에 대해 알아보자

기운찬곰·2023년 8월 5일
3

프론트개발이모저모

목록 보기
16/20
post-thumbnail
post-custom-banner

Overview

최근에 "일주일마다 토이 프로젝트 한 개 만들기"를 진행하고 있습니다. 취업이 안되는 이유 중 하나는 저의 실력을 그 분들이 정확히 가늠하기 어렵다고 판단했을 수도 있기 때문에 결과물을 보여드리는게 역시 제일 낫다는 판단입니다. 진작 이랬으면 좋았을 걸...

아무튼 그 과정에서 전역 상태 관리 도구를 사용할 필요가 생겼습니다. 저는 처음에 recoil을 사용하려고 했습니다만 유지 보수가 여전히 제대로 안되고 있더군요. 이정도면 손을 놓은게 아닌가 싶습니다. 그래서 recoil과 비슷한 jotai를 사용해보기로 했습니다. 하지만 무작정 하려고 하니 좀 어렵더군요. 겨우 겨우 프로젝트 내에는 적용했습니다만 이게 맞는건지 몰라서 jotai 공식 문서를 천천히 읽어보면서 필요한 기능에 대해 알아보는 시간을 가졌으며 이를 블로그 글로 정리해보려고 합니다.


Recoil은 진짜 망했나?

참고 : https://github.com/facebookexperimental/Recoil/discussions/2171

저는 Recoil을 2~3년 전부터 알고 있었습니다. velopert 님 라이브 코딩에서 처음 알게 되었고, 인턴 프로젝트에도 적용한 경험이 있지요. 그래서 그런지 지금도 관심이 많습니다.

npm을 보니 2023년 3월 1일이 마지막 업데이트인 듯한 Recoil 깃허브를 좀 뒤져봤습니다. Discussion을 보니 맨 위에 "Is the project still maintained?" 라는 글이 있더군요. 아마 사람들도 같은 생각인가 봅니다. 😂

글을 읽어보니 메타에서 대량 해고 사태가 있었다고 하네요. 아마 이런 영향도 있나봅니다. 그래도 메타가 외부 maintainers를 위해 Recoil을 열 것이라고 Discord에서 발표했다고 하네요. 다음 번에 Recoil을 찾아봤을때 유지 보수가 되어있기를 바래봅니다.

그래서 지금은 Recoil을 사용하기는 좀 그런거 같고, 그 대안으로 Jotai를 이야기하길래 이걸 찾아봤습니다.


Jotai는 누가 개발했나

참고 : https://twitter.com/dai_shi, https://github.com/dai-shi, https://blog.axlight.com/

Jotai 공식 사이트를 보니 "Daishi Kato" 라는 이름이 보였습니다. 찾아보니 일본인이었습니다. 프리랜서이고 오픈소스 열광자 라고 하네요. 전적을 보니 화려하네요... 특히 Jotai 뿐만 아니라 Zustand 개발에도 maintainer 인 듯합니다. 대단합니다. (이런 개발자가 되려면 어떤 삶을 살아야 할까요? 🤔)

그리고 해당 깃허브 저장소를 찾아보니 Poimandres라고 해서 Open source developer collective(오픈 소스 개발자 모음) 이 별도로 존재했습니다. 오호... 여기서 관리되고 있었군요.

Poimandres Docs를 보니까 유명한 라이브러리가 잔뜩 있더군요. react-spring, use-gesture, react-three-fiber.. 등 zustand, jotai도 여기 있습니다. 이렇게 리액트 생태계가 연결되어있었군요. ㅎㅎ 신기합니다.


Jotai 공식 페이지

Introduction

참고 : https://jotai.org/

Jotai v2에 오신 것을 환영합니다! 리액트 18 및 곧 출시될 use 후크와 완전히 호환됩니다. 이제 리액트 외부(?)에서 사용할 수 있는 스토어 인터페이스가 있습니다. Jotai는 Recoil에서 영감을 받은 글로벌 리액트 상태 관리에 원자적(atomic) 접근을 취합니다.

흔히 Recoil을 알고 있으면 이런 그림 한번쯤 봤을 거 같습니다.

atoms 와 renders을 결합하여 빌드 상태는 atom 의존성을 기반으로 자동으로 최적화됩니다. 이것은 리액트 컨텍스트의 추가적인 리렌더 문제를 해결하고, memoization의 필요성을 없애며, 선언적 프로그래밍 모델을 유지하면서 신호(signals)와 유사한 개발자 경험을 제공합니다.

단순 useState 교체에서 복잡한 요구사항을 가진 엔터프라이즈 TypeScript 응용프로그램으로 확장됩니다. 또한 사용자에게 도움이 되는 많은 유틸리티와 통합 기능이 있습니다!

Getting started

라이브러리를 설치해줍니다.

# pnpm
pnpm install jotai

그런 다음 최상의 개발자 환경(프레임 별)을 위해 React Fast Refresh 지원을 활성화하기 위해 선택적인 SWC 또는 바벨 플러그인을 추가하는 것이 좋습니다. 저는 일단 패스하겠습니다. npm보니 사용은 잘 안하는듯 합니다. 없어도 잘 동작하고요.

Primitive atoms(원시 원자)Derived atoms(파생 원자)를 생성하여 상태를 만듭니다. 이 둘의 차이를 아는 것이 중요합니다.

아래는 Primitive atoms를 만드는 법입니다. 그냥 atom으로 감싸주기만 하면 됩니다.

import { atom } from 'jotai'

const countAtom = atom(0)

const countryAtom = atom('Japan')

const citiesAtom = atom(['Tokyo', 'Kyoto', 'Osaka'])

const animeAtom = atom([
  {
    title: 'Ghost in the Shell',
    year: 1995,
    watched: true
  },
  {
    title: 'Serial Experiments Lain',
    year: 1998,
    watched: false
  }
])

Derived atoms는 자신의 값을 반환하기 전에 다른 원자로부터 읽을 수 있습니다. 아래는 get을 통해 원시 원자인 animeAtom을 받아서 사용하는 Derived atoms인 것을 알 수 있습니다.

const progressAtom = atom((get) => {
  const anime = get(animeAtom) 
  return anime.filter((item) => item.watched).length / anime.length
})

그런 다음 React 구성 요소 내의 원자를 사용하여 상태를 읽거나 씁니다.

원자가 동일한 구성 요소 내에서 읽고 쓸 수 있는 경우 단순성을 위해 결합된 useAtom hooks를 사용하십시오. 흔히 useState 사용하는 것과 동일한 것을 알 수 있습니다.

import { useAtom } from 'jotai'

const AnimeApp = () => {
  const [anime, setAnime] = useAtom(animeAtom)

  return (
    <>
      <ul>
        {anime.map((item) => (
          <li key={item.title}>{item.title}</li>
        ))}
      </ul>
      <button onClick={() => {
        setAnime((anime) => [
          ...anime,
          {
            title: 'Cowboy Bebop',
            year: 1998,
            watched: false
          }
        ])
      }}>
        Add Cowboy Bebop
      </button>
    <>
  )
}

원자 값을 읽거나 쓰는 경우에는 별도로 AtomValue, SetAtom hooks를 사용하여 re-renders를 최적화합니다.

import { useAtomValue, useSetAtom } from 'jotai'

const AnimeList = () => {
  const anime = useAtomValue(animeAtom)

  return (
    <ul>
      {anime.map((item) => (
        <li key={item.title}>{item.title}</li>
      ))}
    </ul>
  )
}

const AddAnime = () => {
  const setAnime = useSetAtom(animeAtom)

  return (
    <button onClick={() => {
      setAnime((anime) => [
        ...anime,
        {
          title: 'Cowboy Bebop',
          year: 1998,
          watched: false
        }
      ])
    }}>
      Add Cowboy Bebop
    </button>
  )
}

const ProgressTracker = () => {
  const progress = useAtomValue(progressAtom)

  return (
    <div>{Math.trunc(progress * 100)}% watched</div>
  )
}

const AnimeApp = () => {
  return (
    <>
      <AnimeList />
      <AddAnime />
      <ProgressTracker />
    </>
  )
}

Next.js 또는 Gatby와 같은 프레임워크를 사용하는 서버 측 렌더링의 경우 루트에 하나 이상의 Provider 구성 요소를 사용해야 합니다. 이 부분은 생략하겠습니다.

API 개요

Jotai는 API를 매우 최소화하고 TypeScript 지향적입니다. 리액트의 통합된 useState 후크처럼 사용이 간단하지만, 모든 상태는 글로벌하게 접근할 수 있고, 파생된 상태는 구현이 용이하며, 불필요한 리렌더는 자동으로 제거됩니다.

아래 예시를 보면 Primitive atoms(원시 원자) 와 Derived atoms(파생 원자)의 차이를 알 수 있습니다. textAtom이 바뀌면 그에 따라 uppercaseAtom도 바뀌죠.

import { atom, useAtom } from 'jotai'

// Create your atoms and derivatives
const textAtom = atom('hello')
const uppercaseAtom = atom(
  (get) => get(textAtom).toUpperCase()
)

// Use them anywhere in your app
const Input = () => {
  const [text, setText] = useAtom(textAtom)
  const handleChange = (e) => setText(e.target.value)
  return (
    <input value={text} onChange={handleChange} />
  )
}

const Uppercase = () => {
  const [uppercase] = useAtom(uppercaseAtom)
  return (
    <div>Uppercase: {uppercase}</div>
  )
}

// Now you have the components
const App = () => {
  return (
    <>
      <Input />
      <Uppercase />
    </>
  )
}

Jotai 패키지는 또한 jotai/utils 번들을 포함합니다. 이러한 추가 기능은 로컬 스토리지에서 원자를 지속적으로 유지하고, 서버 측 렌더링 중에 원자에 hydrating 하며, Redux와 유사한 reducers 및 action types으로 원자를 생성하는 등의 지원을 추가합니다.

아래는 atomWithStorage를 통해 localStorage와 연동해서 저장하는 걸 보여줍니다. zustand와 유사하네요.

import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

// Set the string key and the initial value
const darkModeAtom = atomWithStorage('darkMode', false)

const Page = () => {
  // Consume persisted state like any other atom
  const [darkMode, setDarkMode] = useAtom(darkModeAtom)
  const toggleDarkMode = () => setDarkMode(!darkMode)
  return (
    <>
      <h1>Welcome to {darkMode ? 'dark' : 'light'} mode!</h1>
      <button onClick={toggleDarkMode}>toggle theme</button>
    </>
  )
}

Tutorial Quick start

참고 : https://tutorial.jotai.org/quick-start/intro (추천!!)

보니까 튜토리얼이 있더라고요. 그 중에서 Quick start 먼저 살펴보겠습니다. 코드 에디터는 Sandpack을 사용했나봅니다. 되게 깔끔하네요. React 공식 문서에서도 Sandpack을 사용했길래 그냥 반가웠습니다. codesandbox에서 만든거 같은데 페이지 이동없이 바로 코드를 보고 수정도 해보고 결과를 볼 수 있어서 좋은 거 같습니다.

atom

참고 : https://jotai.org/docs/core/atom

위에서 덜 다룬 부분이 있는데, Derived atoms를 생성할 때 세 가지 패턴이 존재합니다. 처음에 봤을때 이 부분이 조금 혼란스럽긴 했습니다.

  • Read-only atom
  • Write-only atom
  • Read-Write atom

근데 코드를 보면 어려운 내용이 아닙니다. first argument만 구현하면 그건 readOnlyAtom입니다. second argument만 구현하면 그건 writeOnlyAtom 입니다. 둘 다 구현하면 readWriteAtom 이겠죠?

const readOnlyAtom = atom((get) => get(priceAtom) * 2)

const writeOnlyAtom = atom(
  null, // it's a convention to pass `null` for the first argument
  (get, set, update) => {
    // `update` is any single value we receive for updating this atom
    set(priceAtom, get(priceAtom) - update.discount)
  }
)

const readWriteAtom = atom(
  (get) => get(priceAtom) * 2,
  (get, set, newPrice) => {
    set(priceAtom, newPrice / 2)
    // you can set as many atoms as you want at the same time
  }
)

read 함수에 들어가는 get 함수는 원자 값을 읽는 것입니다. 반응적이고 읽기 종속성이 추적(track)됩니다.

write 함수에 들어가는 get도 원자 값을 읽는 것이지만 추적되지 않습니다. 또한 Jotai v1 API에서 확인되지 않은 비동기 값을 읽을 수 없습니다. write 함수에서 set은 atom 값을 쓰는 것입니다. 대상 원자의 write function을 호출합니다.

그니까 결국 정리해보면 atom에는 4가지 경우가 존재하는 군요.

const primitiveAtom = atom(initialValue)
const derivedAtomWithRead = atom(read)
const derivedAtomWithReadWrite = atom(read, write)
const derivedAtomWithWriteOnly = atom(null, write)

이와 관련해서 보면 좋을 예시가 Tutorial Quick start에 존재합니다.

Async Read, Write Atoms

참고 : https://jotai.org/docs/utilities/async

모든 원자는 비동기 읽기 또는 비동기 쓰기와 같은 비동기 동작을 지원합니다. Jotai 는 본질적으로 비동기 흐름을 처리하기 위해 Suspense 활용하고 있습니다.

import { atom, useAtom } from 'jotai';
import { Suspense } from 'react'

const counter = atom(1);
const asyncAtom = atom(async (get) => get(counter) * 5); // ✅

function AsyncComponent() {
  const [asyncCount] = useAtom(asyncAtom);
  return (
    <div className="app">
      <h1>{asyncCount}</h1>
    </div>
  )
}

export default function Page() {
   return (
    <Suspense fallback={<span>loading...</span>}>
      <AsyncComponent />
    </Suspense>
  )
}

그러나 여기에 더 많은 제어를 위한 API가 있습니다.

비동기 원자를 Suspense 하거나 error boundary로 throw 하는 것을 원하지 않는 경우(예: 로드 및 오류 로직의 세분화된 제어) loadable 유틸리티를 사용할 수 있습니다.

{
    state: 'loading' | 'hasData' | 'hasError',
    data?: any,
    error?: any,
}
import { loadable } from "jotai/utils"

const countAtom = atom(0);
const asyncAtom = atom(async (get) => get(countAtom));
const loadableAtom = loadable(asyncAtom)

const AsyncComponent = () => {
  const [value] = useAtom(loadableAtom)
  if (value.state === 'hasError') return <div>{value.error}</div>
  if (value.state === 'loading') {
    return <div>Loading...</div>
  }
  return <div>Value: {value.data}</div>
}

Async Write Atoms 사용 예시입니다. 그니까 request는 async인데 이걸 setValue에 넣어서 사용해도 된다는 거네요. 그리고 이걸 Suspsens로 감싸서 사용하면 loading.. 도 보여줄 수 있고요.

import { atom, useAtom } from 'jotai';
import { Suspense } from 'react'

const todo = {
    id: 0,
    title: 'learn jotai',
    completed: true
};

const request = async () => (
    fetch('https://jsonplaceholder.typicode.com/todos/5')
        .then((res) => res.json())
)
const todoAtom = atom(todo);

function Component() {
  const [todoGoal, setGoal] = useAtom(todoAtom);
  const handleClick = () => {
    setGoal(request()); // ✅
  }
  return (
    <div className="app">
      <p>Todays Goal: {todoGoal.title}</p>
      <button onClick={handleClick}>New Goal</button>
    </div>
  )
}

export default function AsyncSuspense() {
   return (
    <div className="app">
      <Suspense fallback={<span>loading...</span>}>
        <Component />
      </Suspense>
    </div>
  )
}

selectAtom

참고 : https://jotai.org/docs/utilities/select
참고 : https://tutorial.jotai.org/quick-start/official-utils

Recoil에 보면 select라는 기능이 있었잖아요? 저도 처음에는 Jotai를 사용할 때 select부터 찾았습니다만... 조금 다른거 같기도 합니다.

이 함수는 selector에 의해 결정되는 원래 원자의 값의 함수인 유도된 원자를 생성합니다. selector 함수는 원래 원자가 변경될 때마다 실행되며, equalityFn이 파생 값이 변경되었다고 보고하는 경우에만 파생 원자를 업데이트합니다. 기본적으로 equalityFn은 기준 equality이지만 필요한 경우 도출된 값을 안정화하기 위해 선호하는 딥-equals 함수를 제공할 수 있습니다.

제가 봤을때 일반적인 경우라면 Derived atoms을 사용하는 것과 별반 다를게 없어보이고, 한 가지 다른점은 equalityFn를 통해 세부 제어가 가능하다는 정도인거 같습니다.

const defaultPerson = {
    name: {
      first: 'Jane',
      last: 'Doe',
    },
    birth: {
      year: 2000,
      month: 'Jan',
      day: 1,
    }
  }
  
// Original atom.
const personAtom = atom(defaultPerson)

// name.first 또는 name.last가 실제로 변경되지 않더라도 person.name 개체가 변경되면 업데이트됩니다. 
const nameAtom = selectAtom(personAtom, (person) => person.name)

import { isEqual } from 'lodash-es';
// 연도, 월, 일, 시간 또는 분이 변경될 때 업데이트됩니다. deepEquals를 사용하면 birth 필드가 동일한 데이터를 포함하는 
// 새 개체로 대체되는 경우(예: 데이터베이스에서 사람을 다시 읽는 경우) 이 원자가 업데이트되지 않습니다.
const birthAtom = selectAtom(personAtom, (person) => person.birth, isEqual);

즉, 이런식으로 아예 setPerson을 통해 deep copy를 했지만 nameAtom만 업데이트되고, birthAtom은 업데이트 되지 않는데 그 이유가 isEqual 때문인 것이죠. 실제 내용 변경은 없었기 때문에...

// Replace person with a deep copy, triggering a change in nameAtom, but
// not in birthAtom.
const CopyPerson = () => {
  const [person, setPerson] = useAtom(personAtom);
  const handleClick = () => {
    setPerson({
      name: { first: person.name.first, last: person.name.last },
      birth: {
        year: person.birth.year,
        month: person.birth.month,
        day: person.birth.day,
        time: {
          hour: person.birth.time.hour,
          minute: person.birth.time.minute
        }
      }
    });
  };
  return <button onClick={handleClick}>Replace person with a deep copy</button>;
};

아래는 birth의 내용이 달라지기 때문에 birthAtom이 업데이트 됩니다. 단, 관련이 없는 name은 업데이트 되지 않습니다.

// Changes birth year, triggering a change to birthAtom, but not nameAtom.
const IncrementBirthYear = () => {
  const [person, setPerson] = useAtom(personAtom);
  const handleClick = () => {
    setPerson({
      name: person.name,
      birth: { ...person.birth, year: person.birth.year + 1 }
    });
  };
  return <button onClick={handleClick}>Increment birth year</button>;
};

Examples

실제 예시를 통해 Jotai을 알아보면 더 좋습니다. 기본적으로 TodoList와 hackerNews가 있네요. 둘의 차이는 API를 요청하냐 안하냐 차이입니다. 개인적으로 저는 볼 만 했습니다. Jotai가 이런면에서 친절해서 좋네요.

TodoList

참고 : https://tutorial.jotai.org/examples/todolist

확인해보면 꽤 잘 만든것을 알 수 있었습니다. 일단 atom은 3가지가 있었는데, Primitive atoms은 filterAtom와 todosAtom가 있었고, Derived atoms은 filteredAtom 이 있었습니다. 즉, filteredAtom는 filterAtom와 todosAtom를 가져다가 사용하는 녀석입니다.

const filterAtom = atom('all') // all, completed, incompleted
const todosAtom = atom<PrimitiveAtom<Todo>[]>([])

const filteredAtom = atom<PrimitiveAtom<Todo>[]>((get) => {
  const filter = get(filterAtom)
  const todos = get(todosAtom)
  if (filter === 'all') return todos
  else if (filter === 'completed')
    return todos.filter((atom) => get(atom).completed)
  else return todos.filter((atom) => !get(atom).completed)
})

메인 컴포넌트입니다. remove, add 함수가 기본적으로 있고 이는 setTodos를 사용하고 있습니다.

const TodoList = () => {
  // const [, setTodos] = useAtom(todosAtom)
  const setTodos = useSetAtom(todosAtom)
  const remove: RemoveFn = (todo) =>
    setTodos((prev) => prev.filter((item) => item !== todo))
  const add = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const title = e.currentTarget.inputTitle.value
    e.currentTarget.inputTitle.value = ''
    setTodos((prev) => [...prev, atom<Todo>({ title, completed: false })])
  }
  return (
    <form onSubmit={add}>
      <Filter />
      <input name="inputTitle" placeholder="Type ..." />
      <Filtered remove={remove} />
    </form>
  )
}

필터 컴포넌트입니다. 필터 변경시 상태 변경이 일어납니다.

const Filter = () => {
  const [filter, set] = useAtom(filterAtom)
  return (
    <Radio.Group onChange={(e) => set(e.target.value)} value={filter}>
      <Radio value="all">All</Radio>
      <Radio value="completed">Completed</Radio>
      <Radio value="incompleted">Incompleted</Radio>
    </Radio.Group>
  )
}

마지막으로 todo 리스트를 보여주는 부분입니다. 구조를 보면 대충 어떤 방식으로 jotai를 사용하면 될 지 감이 잡힙니다.

const Filtered = (props: FilteredType) => {
  const [todos] = useAtom(filteredAtom)
  const transitions = useTransition(todos, {
    keys: (todo) => todo.toString(),
    from: { opacity: 0, height: 0 },
    enter: { opacity: 1, height: 40 },
    leave: { opacity: 0, height: 0 },
  })
  return transitions((style, atom) => (
    <a.div className="item" style={style}>
      <TodoItem atom={atom} {...props} />
    </a.div>
  ))
}
const TodoItem = ({ atom, remove }: TodoItemProps) => {
  const [item, setItem] = useAtom(atom)
  const toggleCompleted = () =>
    setItem((props) => ({ ...props, completed: !props.completed }))
  return (
    <>
      <input
        type="checkbox"
        checked={item.completed}
        onChange={toggleCompleted}
      />
      <span style={{ textDecoration: item.completed ? 'line-through' : '' }}>
        {item.title}
      </span>
      <CloseOutlined onClick={() => remove(atom)} />
    </>
  )
}

hackerNews

참고 : https://tutorial.jotai.org/examples/hackerNews

이번에는 API 연동과 관련된 예시입니다. Async get atom를 사용한 걸 알 수 있습니다. postId는 별도로 atom을 빼놨군요.

const postId = atom(9001)
const postData = atom(async (get) => {
  const id = get(postId)
  const response = await fetch(
    `https://hacker-news.firebaseio.com/v0/item/${id}.json`
  )
  const data: PostData = await response.json()
  return data
})

그리고 useAtom(postData)를 통해 사용하고 있습니다. Suspense로 감싸면 Loading..을 보여줄 수 있네요.

function PostTitle() {
  const [{ by, text, time, title, url }] = useAtom(postData)
  return (
    <>
      <h2>{by}</h2>
      <h6>{new Date(time * 1000).toLocaleDateString('en-US')}</h6>
      {title && <h4>{title}</h4>}
      {url && <a href={url}>{url}</a>}
      {text && <div>{Parser(text)}</div>}
    </>
  )
}

export default function App() {
  return (
    <Provider>
      <Id />
      <div>
        <Suspense fallback={<h2>Loading...</h2>}>
          <PostTitle />
        </Suspense>
      </div>
      <Next />
    </Provider>
  )
}

Troubleshooting

API 응답 결과 Async Atom 처리 방식

제 경우에는 hackerNews 방식과 동일하게 IdAtom이 존재하고, businessCardAtom이라는 async read 를 만들어서 axios 요청에 대한 응답 값을 저장해주었습니다.

export const businessCardIdAtom = atom(0);

// read-write atom
export const businessCardAtom = atom(
  async (get) => {
    if (get(businessCardIdAtom) === 0) {
      return null;
    }

    const { data } = await axios.get(
      `/api/business-card/${get(businessCardIdAtom)}`
    );
    return BusinessCardValidator.parse(JSON.parse(data));
  },
  async (get, set, update) => {
    set(businessCardAtom, update);
  }
);

businessCardIdAtom에서 set을 사용해서 값을 바꾸면, businessCardAtom가 반응해서 업데이트 되는 구조입니다.

export default function Page({ params }: PageProps) {
  const { id } = params;

  const [, setBusinessCardId] = useAtom(businessCardIdAtom);
  setBusinessCardId(Number(id));

  const businessCard = useAtomValue(businessCardAtom);

  if (!businessCard) return null;

  return (
    <div className="py-[120px] h-full flex flex-col gap-5">
      <BuisnessCardMaker businessCard={businessCard} />

      <div className="flex justify-end gap-4">
        <Button>Publish</Button>
        <Button variant="outline">Cancle</Button>
      </div>
    </div>
  );
}

하지만 이후에 businessCardAtom을 set을 통해 값을 수정하려고 하면 오류가 발생했습니다.

export const businessCardChildAtom = atom(
  async (get) => {
    const childId = get(businessCardChildIdAtom);
    const businessCard = await get(businessCardAtom);

    if (!businessCard || childId === 0) {
      return null;
    }

    return businessCard.children.find((child) => child.id === childId);
  },
  async (get, set, value: BusinessCardType["children"][0]) => {
    const businessCard = await get(businessCardAtom);

    if (!businessCard) {
      return;
    }

    const index = businessCard.children.findIndex(
      (child) => child.id === value.id
    );

    if (index === -1) {
      return;
    }

    const newBusinessCard = {
      ...businessCard,
      children: [
        ...businessCard.children.slice(0, index),
        value,
        ...businessCard.children.slice(index + 1),
      ],
    };

    set(businessCardAtom, newBusinessCard); // error
  }
);

참고 : https://github.com/pmndrs/jotai/issues/352

저 에러에 대해서는 정확히 일치하는 결과는 찾지 못했고, 아무래도 저처럼 사용하려는 예제는 없다보니 저 방식이 잘못된거 같긴합니다. 그래서 위 이슈를 참고해 defaultValueAtom, overwrittenValueAtom를 별도로 만들어서 해보니 되긴 하네요.

export const defaultValueAtom = atom(async (get) => {
  if (get(businessCardIdAtom) === 0) {
    return null;
  }

  const { data } = await axios.get(
    `/api/business-card/${get(businessCardIdAtom)}`
  );
  return BusinessCardValidator.parse(JSON.parse(data));
});

const overwrittenValueAtom = atom(null);

export const businessCardAtom = atom(
  (get) => get(overwrittenValueAtom) || get(defaultValueAtom),
  (_get, set, action: any) => set(overwrittenValueAtom, action)
);

대충 어떤 의미에서 동작하는지 알거 같습니다.


마치면서

이번 시간에는 Jotai에 대해 공식문서와 기본 사용법, 간단한 실습 예제를 살펴보면서 Jotai 에 대해 보다 자세히 알게 된 거 같습니다. 또한, 실습 과정에서 이슈 사항을 겪어보면서 어떻게 해결하면 좋을지 방법도 알게 된 거 같습니다.


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 11월 12일

깔끔하고 쉽게 설명해주셔서 많은 도움 받아갑니다!

답글 달기