[Jotai] Core

RINM·2024년 6월 21일

Study

목록 보기
7/7

Jotai를 본격적으로 사용하기 앞서, 공식 문서에서 필수적인 부분들을 훑어보려 한다. Jotai Core는 atom config라 불리는 atom 선언 함수, store에 있는 atom을 state로 가져와 사용하는 useAtom 훅, 직접 지정한 store를 생성하는 법, store를 컴포넌트에 내려주는 Provider로 구성되어 있다.

Jotai Core

Atom Config

atom()은 실질적인 state인 atom과 구분하여 atom config라 부르는데, immutable object로 atom을 선언하는데만 관여할 뿐, atom value 자체는 store에 저장되기 때문이다.

atom은 크게 primitive atomderived atom으로 나뉜다. primitive atom은 atom config에 initial 값을 제공함으로써 만들 수 있다.

import { atom } from 'jotai'

const priceAtom = atom(10)
const messageAtom = atom('hello')
const productAtom = atom({ id: 12, name: 'good stuff' })

derived atom은 이렇게 만든 primitive atom에서 말 그대로 파생된 atom으로, 부모 atom에서 값을 가져와서 자기만의 값을 가지는 read-only atom, 부모 atom의 값을 조작하고 자신은 아무런 값도 가지지 않는 write-only atom, 그리고 이 두 가지 조작을 모두 할 수 있는 read-write atom이 있다.

Read-only atom

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

atom config 안에 get을 인자로 갖는 함수를 넣어준다. get 함수로 가져오고자하는 부모 atom의 값을 가져오고 필요한 작업을 거쳐 변조된 값을 자신의 atom value로 삼을 수 있다.
이 때 부모 atom의 atom value가 변경되면 자식 atom의 값도 함께 수정된다.

Write-only atom

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)
    // or we can pass a function as the second parameter
    // the function will be invoked,
    //  receiving the atom's current value as its first parameter
    set(priceAtom, (price) => price - update.discount)
  },
)

Write-only atom을 선언할 때는 첫번째 인자에 null을 넘겨줘서 read 기능을 사용하지 않고, 두 번째 인자로 write 행위를 정의한 함수를 넘겨준다. 이 함수는 get, set, [update]를 인자로 가질 수 있는데, set은 부모 atom의 값을 수정하는데 이용하는 함수이다. set의 첫번째 인자로 수정하려는 atom의 이름을, 두 번째 인자로 수정할 값을 그대로 넘겨주거나, callback 함수로 현재 값을 조작할 수 있다.
[update] 부분은 추가 인자값으로 원하는 값을 받아와서 사용할 수 있다.

Read-Write atom

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와 write 기능을 모두 할 수 있는 atom으로, 부모의 값에서 파생한 값을 자기의 atom value로 삼거나(read), 부모의 값을 조작할 수 있다.(write)

요약

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

In Render Function

Atom config는 어디서든 사용할 수 있다. 동적으로 생성하는 것도 가능하다. render 함수 안에서 atom config로 생성해서, useMemo나 useRef로 사용할 수 있다.

const Component = ({ value }) => {
  const valueAtom = useMemo(() => atom({ value }), [value])
  // ...
}

UseMemo vs UseRef

useMemo 훅은 특정한 함수의 결과값을 메모리에 저장하여 똑같은 연산이 여러 번 수행되는 것을 방지한다. useEffect와 비슷한 형태로 첫번째 인자로는 수행할 로직을 콜백함수로, 두번째 인자로 의존성 배열을 받는다.

const value = useMemo(() => {
    return calculate();
},[item])

useRef는 초기값으로 주어진 current를 렌더링 없이 참조하여 변경할 수 있께 해주는 훅으로 마찬가지로 불필요한 리렌더링을 막기 위해서 사용된다. 다른 점이 있다면 useMemo는 어떤 로직의 결과값을 저장해서 연산이 더 일어나는 것을 막는다면, useRef는 리렌더링시에도 초기값을 저장한다는 차이점이 있다.

Jotai에서는 useMemo를 사용하여 rendering 함수에서 atom config를 쓸 것을 권장한다.

Additional Property

debugLabel

Jotai는 react dev tools나 redux dev tools를 사용하여 디버깅할 수 있다. 이때 Debug label이라는 개념이 사용되는데, 디폴트로 지정되는 atom 이름 대신, atom을 잘 설명할 수 있는 이름을 붙여서 디버깅을 용이하게 할 수 있다.

const countAtom = atom(0)
// countAtom's debugLabel by default is 'atom1'
if (process.env.NODE_ENV !== 'production') {
  countAtom.debugLabel = 'count'
  // debugLabel is 'count' now
}

위와 같이 debugLabel 속성 값을 부여하면 이제 디버깅 툴에 countAtom이 count라는 이름으로 잡힌다.
Jotai 디버깅과 관련한 자세한 문서는 여기에.

onMount

onMount 속성에 callback 함수를 넣어서 해당 atom이 마운트 될 때마다 그 콜백 함수가 실행되게 할 수 있다.

const anAtom = atom(1)
anAtom.onMount = (setAtom) => {
  console.log('atom is mounted in provider')
  setAtom(c => c + 1) // increment count on mount
  return () => { ... } // return optional onUnmount function
}

콜백함수에 return 값을 넣어주면 onUnmount도 지정할 수 있다.
여기서 setAtom은 해당 atom에 대한 write로, atom의 값을 변경할 수 있다. 마운트될 때 atom 값을 변경할 수 있게 되는 것이다.

useAtom

useAtom 훅은 store에 저장된 atom을 읽기 위해 사용한다. useState와 거의 비슷한 용법을 가지는데, [value, updateFunction] 형태를 반환한다. 인자로는 생성한 atom config의 이름을 넣어주면 된다.

const anAtom = atom(0)
const [value, setValue] = useAtom(anAtom)

atom config 상태에서는 value가 잡하지 않지만, useAtom을 하는 순간 initial value가 state에 담기게 된다. derived atom의 경우에도 useAtom을 해주어야 read 함수가 호출되어 부모 atom으로부터 값을 가져와 state에 담는다. 모든 컴포넌트에서 더이상 사용하지 않는 atom은(atom config가 사라지고 모든 곳에서 unMounted된) garbage collecting 대상이 되어 사라진다.

반환된 setValue 함수는 오로지 하나의 인자만 받는데, write 함수에 정의된 세번째 인자와 동일하다. 즉, atom state 값을 변경시킬 함수를 얻게 되는 것이다. 정의해둔 read 함수가 없다면 디폴트로 인자로 주어진 값이 그대로 value로 들어간다.

이때 주의해야할 것은 redering 함수 안에서 useAtom으로 새로운 atom을 동적으로 생성하여 사용할 때 무한 루프가 돌 수 있다는 것이다. 아래에서 추천하는 방법을 사용하여 선언하자.

const stableAtom = atom(0)
const Component = () => {
  const [atomValue] = useAtom(atom(0)) // This will cause an infinite loop since the atom instance is being recreated in every render
  const [atomValue] = useAtom(stableAtom) // This is fine
  const [derivedAtomValue] = useAtom(
    useMemo(
      // This is also fine
      () => atom((get) => get(stableAtom) * 2),
      [],
    ),
  )
}

Atom Dependency

Derived atom이 부모 atom의 값을 가져와서 사용하는 만큼, atom 사이에서 dependency 관계가 만들어진다.

설명에 따르면 read 함수가 호출될 때마다 dependency와 depenedent가 새롭게 구성된다. 무슨 말이냐 하면, atom을 선언하는데 있어서는 dependency 관계가 맺어지지 않고, read 함수가 호출되어 atom이 state로 사용될 때만 관계가 지어진다는 것이다.

const uppercaseAtom = atom((get) => get(textAtom).toUpperCase())

예를 들어 uppercaseAtom이라는 derived atom을 선언했다고 하자. 이 atom은 textAtom에 의존 관계가 있지만, 선언시에는 드러나지 않는다. uppercaseAtom을 useAtom으로 가져와서 read 함수가 호출되어야 의존 관계가 생기는 것이다.

useAtomValue & useSetAtom

useAtomValue 훅은 useAtom의 반환값 중 첫번째인 value 값에만 접근할 수 있게 한다. 반대로, useSetAtom은 write function만 가져온다.

const Counter = () => {
  const setCount = useSetAtom(countAtom)
  const count = useAtomValue(countAtom)

  return (
    <>
      <div>count: {count}</div>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </>
  )
}

Counter 예제를 useAtom으로 둘 다 가져오는 대신 위와 같이 useSetAtom과 useAtomValue 훅으로 각각 가져와서 사용할 수 있다. reading 작업 없이 writing만 필요하다면 useSetAtom을 써서 가져오면 된다.

Store

Jotai는 기본적으로 atom을 개별로 생성하고 이것이 디폴트 store에 자동으로 담기는 형식이지만, 사용자가 직접 store를 만드는 것도 가능하다. 이렇게 store를 나누어놓으면 각 component에서 접근하는 store를 구분하여 provide 해줄 수 있다.

createStore

createStore로 새로운 빈 store를 생성한다. 이렇게 만든 store는 provider에 넘겨줄 수 있다.

const myStore = createStore()

const countAtom = atom(0)
myStore.set(countAtom, 1)
const unsub = myStore.sub(countAtom, () => {
  console.log('countAtom value is changed to', myStore.get(countAtom))
})
// unsub() to unsubscribe

const Root = () => (
  <Provider store={myStore}>
    <App />
  </Provider>
)

store는 3가지 메서드를 갖는다. get은 atom value를 가져오고, set은 atom value를 설정하고, sub는 atom이 변화할 때 그것을 추적하기 위해서 구독할 것인지를 결정한다. 위와 같이 countAtom을 구독해놓으면 countAtom의 값이 변경될 때마다 로그가 찍힌다.

getDefaultStore

getDefaultStore는 말 그대로 createStore로 생성되지 않고 기본적으로 생성되어 있는 디폴트 store를 가져온다. provider-less 모드의 store라고도 부르는데, 기본 store의 경우 provider 없이 모든 곳에서 사용할 수 있기 때문이다.

Provider

Provider는 특정 store를 컴포넌트 sub tree로 제공하기 위해서 사용하는 컴포넌트이다. 원하는 컴포넌트의 위에 감싸주면 된다.

const SubTree = () => (
  <Provider>
    <Child />
  </Provider>
)

Provider는 몇개든 사용가능하고, 서로 중첩되거나 nested 되는 것도 가능하다.
Provider를 사용하는 목적에는 크게 3가지가 있다.

  • 컴포넌트 sub tree마다 서로 다른 state를 다루게 하기 위하여
  • 각 atom의 initial value를 지정해주기 위하여
  • atom들이 remount 되는 것을 막기 위하여

Redux나 Zustand와 다르게 Jotai는 각 atom이 buttom-up 방식으로 쌓이기 때문에 사용자가 선언부부터 atom의 store를 일일이 선언해줄 필요는 없지만, 필요시에는 각 store를 분리해 선언해서 각 atom을 등록시켜주어야한다. 동일한 atom이어도 store가 다르면 state 값이 독립적으로 돌아가기 때문에 유용하게 사용할 수 있다. 물론 이렇게 하면 store를 잘 관리하고, 그 스토어를 각 컴포넌트에 제공하는 provider의 위치나 범위도 잘 짜야한다.

물론 전역적으로 모든 atom을 사용해도 된다면 store와 provider를 신경쓰지 않아도 된다.

Provider 컴포넌트는 prop으로 store를 받는다. 이 Provider 하위의 컴포넌트 sub tree에서 어떤 store를 사용할지 지정해주는 작업이다.

const myStore = createStore()

const Root = () => (
  <Provider store={myStore}>
    <App />
  </Provider>
)

useStore

useStore 훅을 사용하면 해당 store를 사용하는 서브 컴포넌트에서 store를 가져와 사용할 수 있다.

0개의 댓글