Composing atoms
라이브러리에서 제공하는 atom
함수는 매우 기본적이지만, 여러 개의 아톰을 조합하여 기능을 구현할 수 있을 만큼 유연합니다.
다시 한 번 강조하자면,
atom()
은 아톰의 동작을 정의하는 객체인 아톰 설정을 생성합니다.
아톰을 파생시키는 방법을 다시 한 번 살펴보겠습니다.
기본 파생 아톰
가장 간단한 파생 아톰 예제를 살펴보겠습니다.
export const textAtom = atom('hello')export const textLenAtom = atom((get) => get(textAtom).length)
textLenAtom
은 읽기 전용 아톰이라고 불립니다. write
함수가 정의되어 있지 않기 때문입니다.
다음은 write
함수가 포함된 간단한 예제입니다.
const textAtom = atom('hello')export const textUpperCaseAtom = atom((get) => get(textAtom).toUpperCase(),(_get, set, newText) => set(textAtom, newText),)
이 경우, textUpperCaseAtom
은 원본 textAtom
을 설정할 수 있습니다. 따라서 textUpperCaseAtom
만 내보내고 textAtom
은 더 작은 범위에 숨길 수 있습니다.
이제 실제 예제를 살펴보겠습니다.
기본 아톰 값 재정의하기
읽기 전용 아톰이 있다고 가정해 보겠습니다. 읽기 전용 아톰은 쓰기가 불가능하지만, 두 개의 아톰을 결합하여 읽기 전용 아톰의 값을 재정의할 수 있습니다.
const rawNumberAtom = atom(10.1) // 외부로 내보낼 수 있음const roundNumberAtom = atom((get) => Math.round(get(rawNumberAtom)))const overwrittenAtom = atom(null)export const numberAtom = atom((get) => get(overwrittenAtom) ?? get(roundNumberAtom),(get, set, newValue) => {const nextValue =typeof newValue === 'function' ? newValue(get(numberAtom)) : newValueset(overwrittenAtom, nextValue)},)
최종적으로 numberAtom
은 atom(10)
과 같은 일반적인 원시 아톰처럼 동작합니다.
숫자 값을 설정하면 overwrittenAtom
값이 재정의되고,
null
을 설정하면 roundNumberAtom
값이 사용됩니다.
이 재사용 가능한 구현은 jotai/utils
의 atomWithDefault
로 제공됩니다.
자세한 내용은 atomWithDefault를 참조하세요.
다음으로, 외부 값과 동기화하는 또 다른 예제를 살펴보겠습니다.
외부 값과 atom 값 동기화하기
외부 값과 동기화해야 할 경우가 있습니다.
localStorage
가 대표적인 예이고, window.title
도 있습니다.
localStorage
와 동기화된 atom을 만드는 방법을 살펴보겠습니다.
const baseAtom = atom(localStorage.getItem('mykey') || '')export const persistedAtom = atom((get) => get(baseAtom),(get, set, newValue) => {const nextValue =typeof newValue === 'function' ? newValue(get(baseAtom)) : newValueset(baseAtom, nextValue)localStorage.setItem('mykey', nextValue)},)
persistedAtom
은 기본 atom처럼 동작하지만, 그 값은 localStorage
에 저장됩니다.
이 기능은 재사용 가능한 구현체로 jotai/utils
의 atomWithStorage
에서 제공합니다. 자세한 내용은 atomWithStorage를 참고하세요.
하지만 이 방법에는 주의할 점이 있습니다. atom 설정은 값을 보유하지 않지만, 외부 값은 단일 값(singleton value)입니다.
따라서 이 atom을 두 개의 다른 Provider에서 사용하면, 두 persistedAtom
값 사이에 불일치가 발생할 수 있습니다.
이 문제는 외부 값에 구독 메커니즘이 있다면 해결할 수 있습니다.
예를 들어, jotai-valtio
의 atomWithProxy
는 구독 기능을 제공하므로 이런 제약이 없습니다.
서로 다른 Provider의 값도 동기화됩니다.
다시 본론으로 돌아가서, 다른 예제를 살펴보겠습니다.
atomWith*
유틸리티로 아톰 확장하기
atomWith
로 시작하는 여러 유틸리티가 있습니다.
이들은 특정 기능을 가진 아톰을 생성합니다.
하지만 두 개의 아톰 유틸리티를 결합할 수는 없습니다.
예를 들어, atomWithStorage
와 atomWithReducer
를
하나의 아톰을 정의하는 데 함께 사용할 수 없습니다.
이런 경우에는 직접 아톰을 파생시켜야 합니다.
atomWithStorage
에 리듀서 기능을 추가해 보겠습니다.
const reducer = ...const baseAtom = atomWithStorage('mykey', '')export const derivedAtom = atom((get) => get(baseAtom),(get, set, action) => {set(baseAtom, reducer(get(baseAtom), action))})
이 경우에는 atomWithReducer
가 atomWithStorage
에 비해
구현이 간단하기 때문에 쉽게 할 수 있습니다.
더 복잡한 경우에는 쉽지 않을 수 있습니다.
이것은 여전히 열린 연구 분야로 남아 있습니다.
마지막으로, 액션을 사용한 또 다른 예 제를 살펴보겠습니다.
액션 아톰
이 패턴은 README에 설명되어 있어 이미 익숙할 것입니다. 하지만 다시 한 번 살펴보는 것이 도움이 될 수 있습니다.
1씩만 증가하거나 감소할 수 있는 카운터를 만들어 봅시다.
한 가지 해결책은 atomWithReducer
를 사용하는 것입니다:
const countAtom = atomWithReducer(0, (prev, action) => {if (action === 'INC') {return prev + 1}if (action === 'DEC') {return prev - 1}throw new Error('unknown action')})
이 방법은 괜찮지만, 아주 원자적(atomic)이라고 할 수는 없습니다. 코드 분할(code splitting)이나 지연 로딩(lazy loading)의 이점을 얻고 싶다면, 쓰기 전용 아톰 또는 액션 아톰을 만들어야 합니다.
const baseAtom = atom(0) // 외부로 노출하지 않음export const countAtom = atom((get) => get(baseAtom)) // 읽기 전용export const incAtom = atom(null, (_get, set) => {set(baseAtom, (prev) => prev + 1)})export const decAtom = atom(null, (_get, set) => {set(baseAtom, (prev) => prev - 1)})
이 방식은 더 원자적이며, Jotai 스타일에 더 가깝습니다.
또한, 다른 액션 아톰을 호출하는 액션 아톰을 만들 수도 있습니다:
// 이전 코드에서 이어짐export const dispatchAtom = atom(null, (_get, set, action) => {if (action === 'INC') {set(incAtom)} else if (action === 'DEC') {set(decAtom)} else {throw new Error('unknown action')}})
이렇게 하는 이유는 필요할 때만 사용되도록 하기 위함입니다. 이는 코드 분할과 데드 코드 제거를 가능하게 합니다.
요약
아톰은 기본 구성 요소입니다.
다른 아톰을 기반으로 아톰을 합성하면 복잡한 로직을 구현할 수 있습니다.
이는 읽기 전용 파생 아톰뿐만 아니라 쓰기 동작 아톰에도 적용됩니다.
본질적으로 아톰은 함수와 유사하므로, 아톰을 합성하는 것은
함수를 다른 함수와 합성하는 것과 같습니다.
참고: 아톰은 어떤 종류의 데이터도 포함할 수 있다고 언급했습니다.
문자열, Blob, Observer 등 무엇이든 가능합니다.
단, 한 가지 예외가 있습니다. 파생 아톰은 함수를 사용해 정의되기 때문에,
순수한 getter가 아닌 함수를 전달하면 Jotai가 이해하지 못합니다.
이 경우, 함수를 객체로 감싸면 됩니다.
const doublerAtom = atom({ callback: (n) => n * 2 })// 사용 예시const [doubler] = useAtom(doublerAtom)const doubledValue = doubler.callback(50) // 100으로 계산됨