JotaiJotai

상태
React를 위한 기본적이고 유연한 상태 관리

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)) : newValue
set(overwrittenAtom, nextValue)
},
)

최종적으로 numberAtomatom(10)과 같은 일반적인 원시 아톰처럼 동작합니다. 숫자 값을 설정하면 overwrittenAtom 값이 재정의되고, null을 설정하면 roundNumberAtom 값이 사용됩니다.

이 재사용 가능한 구현은 jotai/utilsatomWithDefault로 제공됩니다. 자세한 내용은 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)) : newValue
set(baseAtom, nextValue)
localStorage.setItem('mykey', nextValue)
},
)

persistedAtom은 기본 atom처럼 동작하지만, 그 값은 localStorage에 저장됩니다.

이 기능은 재사용 가능한 구현체로 jotai/utilsatomWithStorage에서 제공합니다. 자세한 내용은 atomWithStorage를 참고하세요.

하지만 이 방법에는 주의할 점이 있습니다. atom 설정은 값을 보유하지 않지만, 외부 값은 단일 값(singleton value)입니다.
따라서 이 atom을 두 개의 다른 Provider에서 사용하면, 두 persistedAtom 값 사이에 불일치가 발생할 수 있습니다.
이 문제는 외부 값에 구독 메커니즘이 있다면 해결할 수 있습니다.

예를 들어, jotai-valtioatomWithProxy는 구독 기능을 제공하므로 이런 제약이 없습니다.
서로 다른 Provider의 값도 동기화됩니다.

다시 본론으로 돌아가서, 다른 예제를 살펴보겠습니다.

atomWith* 유틸리티로 아톰 확장하기

atomWith로 시작하는 여러 유틸리티가 있습니다.
이들은 특정 기능을 가진 아톰을 생성합니다.
하지만 두 개의 아톰 유틸리티를 결합할 수는 없습니다.
예를 들어, atomWithStorageatomWithReducer
하나의 아톰을 정의하는 데 함께 사용할 수 없습니다.

이런 경우에는 직접 아톰을 파생시켜야 합니다.
atomWithStorage에 리듀서 기능을 추가해 보겠습니다.

const reducer = ...
const baseAtom = atomWithStorage('mykey', '')
export const derivedAtom = atom(
(get) => get(baseAtom),
(get, set, action) => {
set(baseAtom, reducer(get(baseAtom), action))
}
)

이 경우에는 atomWithReduceratomWithStorage에 비해
구현이 간단하기 때문에 쉽게 할 수 있습니다.

더 복잡한 경우에는 쉽지 않을 수 있습니다.
이것은 여전히 열린 연구 분야로 남아 있습니다.

마지막으로, 액션을 사용한 또 다른 예제를 살펴보겠습니다.

액션 아톰

이 패턴은 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으로 계산됨