Recoil은 React의 상태 관리 라이브러리 중 한 종류이다. React와 기존 상태 관리 라이브러리의 문제점을 개선하기 위해 만들어진 새로운 개념의 라이브러리라 할 수 있겠다.
redux-thunk
, redux-saga
등의 복잡한 외부 라이브러리가 추가로 필요함.Atom은 state의 단위이다. 갱신(값 업데이트) 및 구독(컴포넌트에서 불러와 사용)이 가능하며, atom이 갱신되면 그것을 구독하는 모든 컴포넌트가 리렌더링되며 새로운 값을 즉각 반영한다.
atom은 이렇게 정의한다.
const fontSizeState = atom({
key: 'fontSizeState',
default: 14,
});
key
는 디버깅이나 특정 API를 위해 사용되는 값이므로, 다른 atom과 중복되지 않아야 한다.
리액트의 useState
처럼 default value를 정의해줄 수도 있다.
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
return (
<button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
Click to Enlarge
</button>
);
}
컴포넌트에서 atom을 사용할 땐 useRecoilState
hook을 통해 불러오도록 한다. useState
와 매우 유사한 형태이지만, 서로 다른 컴포넌트끼리 동일한 atom 값을 공유할 수 있다는 차이가 있다.
위 setFontSize()에서, 단순히 새로운 state 값을 반환하는 대신, Updater 형식으로 state를 어떻게 조작할지 정의해준 것을 볼 수 있다.
((size) => size + 1)
이렇게 작성하면 기존의 state 값(size
)를 참조할 수 있다는 장점이 있다.
Selector는 다른 atom이나 selector를 인자로 받는 순수 함수이다. 인자로 받은 다른 atom 및 selector가 갱신되면 해당 selector도 재평가되며, 이를 구독하고 있는 컴포넌트 역시 리렌더링된다.
const fontSizeLabelState = selector({
key: 'fontSizeLabelState',
get: ({get}) => {
const fontSize = get(fontSizeState);
const unit = 'px';
return `${fontSize}${unit}`;
},
});
selector도 atom처럼 key
를 가지며, get
프로퍼티의 값은 계산을 위한 함수이다. 인자 get
(프로퍼티 이름과 구별할 것!)을 통해 다른 atom 및 selector의 값을 가져올 수 있으며, 이를 이용해 새로운 값을 만들어 반환할 수 있다.
어떤 state를 담고 있다는 점은 atom과 같지만, 내부적으로 다른 state를 가져와서 써먹을 수 있다는 점이 차이인 것 같다. (= derived state)
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
const fontSizeLabel = useRecoilValue(fontSizeLabelState);
return (
<>
<div>Current font size: {fontSizeLabel}</div>
<button onClick={() => setFontSize(fontSize + 1)} style={{fontSize}}>
Click to Enlarge
</button>
</>
);
}
방금 정의한 selector는 atom과 달리 not writable(수정 불가)하므로
useRecoilValue
hook을 통해 값을 가져와야 한다. (리턴 값에 setter가 따로 없는 것을 확인할 수 있다)
=>set
프로퍼티가 있는 selector만 writable함! (자기 자신이 아닌 다른 atom의 값을 수정하는 방법을 정의한 프로퍼티)
그리고 이러한 recoil state를 사용하고자 한다면 해당 컴포넌트들의 공통 조상에 RecoilRoot
컴포넌트가 존재해야 한다.
function App() {
return (
<RecoilRoot>
<CharacterCounter />
</RecoilRoot>
);
}
보통은 이렇게 최상단 루트 컴포넌트를 감싸주면 된다.
앞서 설명한 Recoil의 장점 중 하나는 비동기 데이터 처리가 간편하다는 것이다.
Recoil은 데이터 흐름 그래프를 통해 상태를 컴포넌트에 매핑하는데, 이 그래프 내의 함수를 쉽게 비동기화할 수 있다고 한다.
첫 번째 방법은 그냥 selector의 get
프로퍼티가 값 자체가 아닌 프로미스를 리턴하도록 정의하면 된다!
const currentUserNameQuery = selector({
key: 'CurrentUserName',
get: async ({get}) => {
const response = await myDBQuery({
userID: get(currentUserIDState),
});
if (response.error) {
throw response.error;
}
return response.name;
},
});
function CurrentUserInfo() {
const userName = useRecoilValue(currentUserNameQuery);
return <div>{userName}</div>;
}
위 예제의 selector를 보면 get
프로퍼티가 async 함수로 정의되었으며, myDBQuery
라는 비동기 함수를 await
키워드와 함께 호출하여 데이터를 가져온 후 리턴하고 있다. 인자인 get
은 역시 다른 atom(currentUserIDState
)을 불러오는 데에 쓰였다.
selector는 기본적으로 캐싱 기능을 갖고 있으므로, 이전과 동일한 입력이 들어오면 다시 요청을 보내지 않고 기억해둔 이전의 값을 그대로 반환한다.
function MyApp() {
return (
<RecoilRoot>
<ErrorBoundary>
<React.Suspense fallback={<div>Loading...</div>}>
<CurrentUserInfo />
</React.Suspense>
</ErrorBoundary>
</RecoilRoot>
);
}
단, 리액트의 렌더 함수는 동기적으로 작동하므로 Recoil이 비동기 데이터를 처리할 동안 React.Suspense
컴포넌트를 통해 fallback UI를 보여주도록 해야 한다.
또한 에러가 발생했을 때 전체 앱을 중단시키지 않도록 ErrorBoundary
컴포넌트를 감싸주었다.
function UserInfo({userID}) {
const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
switch (userNameLoadable.state) {
case 'hasValue':
return <div>{userNameLoadable.contents}</div>;
case 'loading':
return <div>Loading...</div>;
case 'hasError':
throw userNameLoadable.contents;
}
}
만약 React.Suspense
를 쓰기 싫다면 대신 useRecoilValueLoadable
hook을 이용해 현재 state의 상태에 따라 적절하게 UI 처리를 해줄 수도 있다. (= try-catch문 대체!)
class
Loadable
atom 및 selector의 현재 상태를 나타내며,
state
와contents
라는 두 가지 프로퍼티를 가짐.
state
:'hasValue'
,'hasError'
,'loading'
중 하나.contents
: 위의state
상태에 따라 다른 값을 담고 있음.
- 1)
state == 'hasValue'
일 경우 실제 값- 2)
state == 'hasError'
일 경우Error
객체- 3)
state == 'loading'
일 경우Promise
만약 비동기 함수를 호출할 때 따로 인자를 넣어주고 싶다면 selector
대신 selectorFamily
를 사용한다.
const userNameQuery = selectorFamily({
key: 'UserName',
get: userID => async () => {
const response = await myDBQuery({userID});
if (response.error) {
throw response.error;
}
return response.name;
},
});
function UserInfo({userID}) {
const userName = useRecoilValue(userNameQuery(userID)); // 인자와 함께 호출
return <div>{userName}</div>;
}
selector와 매우 유사하지만, get
프로퍼티가 인자(userID
)를 받는 부분이 추가되어 async 함수 자체를 반환하도록 정의된다.
비동기 호출을 여러 번 하는 경우, 순서대로 처리하다보면 성능에 영향을 줄 수 있다. 이때는 비동기 작업으로 얻는 배열 또는 객체를 get(waitForAll(...))
으로 감싸줘서 병렬적으로 요청을 보내도록 할 수 있다.
const friendsInfoQuery = selector({
key: 'FriendsInfoQuery',
get: ({get}) => {
const {friendList} = get(currentUserInfoQuery);
const friends = get(waitForAll(
friendList.map(friendID => userInfoQuery(friendID))
));
return friends;
},
});
위 코드에서 friends
배열의 각 항목은 동시에 병렬적으로 처리가 진행되며, 모든 항목이 처리된 후 업데이트된 상태를 반환한다.
반면, waitForNone()
이라는 비슷한 함수도 있는데, 이는 호출 즉시 Loadable
객체를 리턴한다. 실시간으로 요청에 대한 완료 여부를 파악할 수 있는 것이 차이점이다.
그동안 리액트 상태 관리 라이브러리로 Redux를 사용하면서 종종 불편한 점을 느끼곤 했었는데, Recoil은 정말 '이게 끝이라고?' 하는 생각이 들 정도로 가볍고 깔끔하게 느껴졌다.
좀 더 구체적인 장단점은 직접 사용해 봐야 알겠지만, 일단 첫인상은 굉장히 좋았다. 어서 새 프로젝트에 써보고 싶다.