- Published on
Recoil atomFamily를 통해 여러 개의 Atom 관리하기(with Typescript)
상태 관리를 위해 Recoil을 도입한 프로젝트에서, 앱 전역에서 공통적으로 사용할 모달을 관리해야 하는 상황이 있었습니다.
각 모달은 모두 공통적인 속성(보임 여부, 열렸을 때 실행할 작업, 제목 등)을 가지고 있으면서도, 앱 내에서는 서로 다른 모달이 여러 개 생성되어야 했습니다.
이를 위해 Recoil의 atomFamily
API를 이용해 각각의 모달을 개별적인 Atom으로 관리하기로 결정했습니다.
본 글은 atomFamily
에 대한 소개와 함께 타입스크립트에서 atomFamily
와 selectorFamily
API를 이용해 같은 성격을 가진 다수의 Atom을 관리하는 과정을 정리했습니다.
atomFamily
API
Recoil의 상태 관리를 할 때 다수의 객체를 배열의 형태로 관리해야 하는 경우가 있습니다.
예를 들어, 여러 개의 게시글을 저장하기 위해 Post[]
형태의 Atom인 postList
를 정의하고, 해당 Atom에 여러 개의 Post
를 저장해 관리할 수 있을 것입니다.
const postList = atom<Post[]>({
key: "postList",
default: [],
...
})
하지만 이 패턴은 Redux와 동일하게 한 개의 post
에 변경이 일어날 때마다 postList
Atom에 매번 새로운 배열이 할당 되어야하고,
postList
를 이용해 여러개의 post
를 렌더링할 때 결국 별도의 memoization이 필요할 것입니다.
또한 Recoil의 사상에 따르면 Atom은 고유한 key
를 갖는, 컴포넌트가 구독할 수 있는 가장 작은 단위의 상태값(원자값)이 되도록 구성하는 것이 이상적입니다.
따라서 한 배열 안에 여러 개의 post
를 관리하는 패턴은 작동엔 문제가 없으나 Recoil의 설계에는 정확하게 들어맞지 않는 패턴입니다.
참고로, Recoil의 공식 문서에서도
서로 다른 요소(element)를 개별 Atom으로 관리하고, 각 컴포넌트에서는 필요한 개별 Atom만을 구독함으로써 렌더링에서의 이점이 생긴다
라고 소개하고 있습니다.
그렇다고 할지라도 아래와 같이 Post
타입을 가지는 Atom을 여러 개 정의하는 것은 그다지 바람직하지 않아보입니다.
const post1Atom = atom<Post>({
key: "post1",
...
})
const post2Atom = atom<Post>({
key: "post2",
...
})
...
이 경우에 atomFamily
API를 사용할 수 있습니다.
atomFamily
는 동일한 형태의 atom을 생성해주는 팩토리 함수를 제공합니다(정확히 말하면, 팩토리 함수를 리턴하는 함수를 리턴합니다).
이름에서 혼동이 올 수 있지만, atomFamily
는 그 자체로 Atom을 관리할 수 있는 배열을 제공해주는 API는 아닙니다. 즉, atomFamily
를 호출할 때마다 지정한 형식의 Atom을 생성해내게 됩니다.
AtomFamily 정의
본격적으로 atomFamily
를 이용해 모달을 정의할 차례입니다.
다음과 같이 modalsAtomFamily
를 정의했습니다.
const modalsAtomFamily = atomFamily<ModalInfo, ModalId>({
key: "modalsAtomFamily",
default: (id) => ({
id,
isOpen: false,
title: "",
}),
})
atomFamily
는 일반적인 Atom과 동일하게, 파라미터 객체의 default
영역에 이 atomFamily
로 만들어질 Atom의 초기값을 지정할 수 있습니다.
다만 atom
API를 이용한 Atom 생성과의 차이점은 default
값이 특정한 파라미터를 받는 함수가 될 수 있다는 점입니다.
위 코드에서는 모달을 구분하기 위해 id
라는 파라미터를 받게 했고, 이 id
값을 이용해 만들어질
Atom의 id
값을 정해주었습니다.
또한, 타입을 정의하기 위해서 설정한 제네릭의 각 값의 역할은 다음과 같습니다.
ModalInfo
: 생성될 Atom의 타입ModalId
:default
값이 파라미터를 받는 함수인 경우, 그 파라미터의 타입. 이 프로젝트에서는 모달의id
로 지정할 수 있는 값이 정해져 있습니다.
이제 이 atomFamily
를 이용해 다음과 같이 Atom을 계속 생성해낼 수 있습니다.
const [myModal, setMyModal] = useRecoilState(modalsAtomFamily("myModal"))
const [yourModal, setYourModal] = useRecoilState(modalsAtomFamily("yourModal"))
key 관리의 필요성
이와 같이 atomFamily
를 이용하면 동일한 형식의 Atom을 쉽게 여러 번 만들어낼 수 있지만, 또 다른 문제점이 있습니다.
atomFamily
는 Atom을 생성해주는 팩토리 함수일 뿐이기에, 지금까지 이 atomFamily
를 이용해서 어떤 Atom들을 생성했는지는 전혀 알 수 없습니다.
예를 들어, 현재 열려 있는 모달들을 한 번에 닫는 등의 작업 등은 atomFamily
만으로는 수행할 수 없을 것입니다.
따라서, atomFamily
와 동시에 atomFamily
를 통해 생성된 Atom의 key를 별도로 관리해주는 작업이 필요합니다(위 예시에서는 ModalId
).
이를 위해 modalIdsAtom
이라는 Atom을 만들어 atomFamily
를 이용해 모달 Atom을 생성할 때마다 해당 모달의 ModalId
를 추가했습니다.
export const modalIdsAtom = atom<ModalId[]>({
key: "modalIdsAtom",
default: [],
})
결국 새 모달을 생성하고자 할 때에는 아래와 같이 두 단계의 작업이 필요하다는 의미입니다.
const setModalIdsAtom = useSetRecoilState(modalIdsAtom)
/* 1. atomFamily로 모달 Atom 생성 */
const myModal = modalsAtomFamily("myModal")
/* 2. 생성한 Atom의 key를 별도의 배열에 넣기 */
setModalIdsAtom((prev) => [...prev, modalIdsAtom])
이 작업을 별도의 hook(이를테면 useModal
등)으로 분리할 수도 있겠지만, recoil의 selector
를 이용해 해결했습니다.
selectorFamily
API
Recoil의 Recoil에서 selector
는 redux와 동일하게 특정한 Atom을 기반으로 해서 파생된 상태(derived state)를 만들어 냅니다. 하지만 차이점이 있다면, Recoil의 selector
는 set
값을 이용해
쓰기 가능한 상태(writable state)를 정의할 수 있습니다. set
은 특정한 타입의 파라미터를 받는데, 이 타입의 파라미터를 이용해 다른 Recoil Atom을 업데이트하는 용도로 사용할 수 있습니다.
여기서, selector
와 selectorFamily
의 관계는 atom
과 atomFamily
와의 관계와 동일합니다. 즉, selectorFamily
는 한 파라미터(위 예시에서는 ModalId
)를 받아 이 파라미터를 이용해 작업을 수행하는
selector
를 리턴하는 팩토리 함수를 리턴합니다.
selectorFamily
가 set
을 이용해 다른 Atom을 업데이트할 수 있다는 점에 착안하여, 모달을 생성하고 모달 ID를 관리하는 작업을 아래와 같이 정리할 수 있습니다.
export const modalsSelectorFamily = selectorFamily<ModalInfo, ModalId>({
key: "modalsSelectorFamily",
get: (modalId) => ({ get }) => get(modalsAtomFamily(modalId)),
set: (modalId) => ({ get, set, reset }, modalInfo) => {
if (modalInfo instanceof DefaultValue) {
reset(modalsAtomFamily(modalId))
set(modalIdsAtom, (prevValue) => prevValue.filter((item) => item !== modalId))
return
}
set(modalsAtomFamily(modalId), modalInfo)
set(modalIdsAtom, (prev) => Array.from(new Set([...prev, modalInfo.id])))
},
})
좀 더 자세히 살펴보면 다음과 같습니다.
get
1. useRecoilState
나 useRecoilValue
를 통해 modalsSelectorFamily("myModal")
의 값을 얻어오면 get(modalsAtomFamily(modalId))
에 의해 modalsAtomFamily("myModal")
의 값을 가져옵니다.
따라서 myModal
이라는 키를 가진 atomFamily
가 기존에 생성된 적이 있었다면 그 값을 읽어올 것입니다.
하지만 get
내부의 syntax를 살펴보면 생기는 의문이 있습니다. atomFamily
는 Atom을 생성하는 팩토리 함수를 리턴하는데, get(modalsAtomFamily(modalId))
와 같이 작성한다면 ID가 동일한 Atom을 매번 새롭게 생성하는 것이 아닐까요?
사실은 그렇지 않습니다. Recoil은 동일한 atomFamily
로 생성된 Atom들을 구분하기 위해 atomFamily
에 넘겨준 파라미터를 내부적인 ID로 이용합니다(자세한 내용).
가령, 아래와 같이 작성했을 때
const myModal = useRecoilValue(modalsAtomFamily("myModal"))
const notMyModal = useRecoilValue(modalsAtomFamily("myModal"))
myModal
과 notMyModal
은 같은 Atom의 값을 가리키게 될 것입니다.
set
2. set
값에서는 첫 번째 파라미터로 set
내부에서 다른 Recoil 상태를 읽거나 업데이트할 때 사용하는 get
, set
, reset
함수를 가져옵니다.
두 번째 파라미터는 특정한 값을 받는데, 이 값을 이용해 다른 상태를 읽거나 업데이트 할 수 있습니다.
- 먼저
set
으로 받은 파라미터의 타입이DefaultValue
인지 체크합니다.useResetRecoilState
를 이용해 Atom을 기본값으로 초기화 하려는 경우입니다. 이 경우 전달 받은reset
함수를 이용해modalsAtomFamily(modalId)
를 초기화 하고, 동시에ModalId
키를 관리하고 있는modalIdsAtom
에서 해당modalId
를 제거해줍니다.
...
if (modalInfo instanceof DefaultValue) {
/* 키가 modalId인 Atom 리셋 */
reset(modalsAtomFamily(modalId))
/* 해당 modalId 제거 */
set(modalIdsAtom, (prevValue) => prevValue.filter((item) => item !== id))
return
}
...
set
으로 받은 파라미터의 타입이DefaultValue
가 아니라면useSetRecoilState
훅 등을 통해 값을 할당하려는 경우입니다. 전달 받은set
함수를 이용해modalsAtomFamily(modalId)
를 원하는 값(set
의 두 번째 파라미터로 받은 값)으로 설정하고, 마찬가지로modalIdsAtom
에서 해당modalId
를 추가해줍니다. 다만, 같은modalId
에 여러 번 값을 할당하더라도 같은 키는 한 번만 추가되어야 하므로Set
객체를 이용했습니다.
...
set(modalsAtomFamily(modalId), modalInfo)
set(modalIdsAtom, (prev) => Array.from(new Set([...prev, modalInfo.id])))
...
사용하기
최종적으로, modalsSelectorFamily
를 이용해 앱 내 모달을 제어하는 useModal
훅을 아래와 같이 작성할 수 있습니다.
const useModal = (modalId: ModalId) => {
const [modal, setModal] = useRecoilState(modalsSelectorFamily(modalId))
const resetModal = useResetRecoilState(modalsSelectorFamily(modalId))
...
const openModal = () => {
setModal((current) => ({ ...current, isOpen: true }))
}
const hideModal = () => {
setModal((current) => ({ ...current, isOpen: false }))
}
const closeModal = () => {
resetModal()
}
...
return { modal, setModal, openModal, hideModal, destroyModal, ... }
}
...
앞서 작성한 modalsSelectorFamily
를 이용하여 모달을 생성하고, 열림/닫힘 상태를 설정하거나, 혹은 모달의 데이터를 초기화 시키는 로직을 모듈화할 수 있습니다.
실제 컴포넌트에서는
...
const loginModal = useModal("loginModal")
loginModal.openModal()
...
처럼 사용해 모달을 제어할 수 있을 것입니다. 물론 서로 다른 페이지에서 loginModal
키를 이용해 useModal
을 여러 번 호출 하더라도 atomFamily
의 특성에 의해
모두 같은 모달을 가리키게 될 것입니다.