현재 내가 재직중인 회사에서는 async data query 용도로는 react-query를, async 데이터가 포함되지 않는 전역 상태 관리에는 recoil을 사용하고 있다. react-query는 async data query에 굉장히 특화된 라이브러리로, 사용이 아주 쉽고 Stale-While-Revalidate 원칙에 맞게 잘 구현되어 있어서 사용자에게도 좋은 경험을 가져다준다.
그래서 (사용할 일이 없으니) recoil의 async data query 쪽에는 관심을 안가지고 있었는데, 꽤나 괜찮은 인터페이스를 제공하고 있는 것 같아서 한번 사용해봐야겠다는 생각이 들었다.
사용자는 사용자 리스트를 볼 수 있고, 사용자 하나를 클릭하면 가장 최근에 누른 사용자 정보를 상세하게 보여준다.
간단한 샘플 유저 데이터를 반환해주는 json placeholder api를 활용한다
샘플 데이터
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
}
recoil을 통해서 async 데이터를 받아오는 방법은 아주 간단하다. 기존에 다른 atom/selector에 의존하는 데이터를 만들 때 썼던 selector를 사용하되, get
콜백의 반환값으로 Promise
를 주면 된다.
우선, 사용자 리스트를 받아오는 selector를 선언해보자. 편의를 위해 axios
라이브러리를 사용했다.
const userListAtom = selector<UserType[]>({
key: "USER_LIST",
get: async () => {
const { data } = await axios.get<UserType[]>(
"https://jsonplaceholder.typicode.com/users"
);
return data;
}
});
async 함수의 반환값은 Promise이기 때문에, 이렇게만 해주면 선언이 끝난다.
다음은 사용자 한 명의 데이터를 받아오는 selector다. 이 selector는 userId를 받아서 해당 userId에 해당하는 사용자 데이터를 내려줘야 하기 때문에, selectorFamily
로 선언해준다.
const userAtom = selectorFamily<UserType, number>({
key: "USER",
get: (userId) => async () => {
const { data } = await axios.get<UserType>(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
return data;
}
});
마찬가지로 간단하게 선언이 끝났다.
이제 이 selector를 활용해서 사용자 데이터를 실제로 받아온 뒤, 렌더링에 사용해보자. async selector의 경우에는 그냥 사용하면 안되고, Suspense
+ ErrorBoundary
로 컴포넌트를 감싸주거나 (각각 loading, error 상태 핸들링을 위해서 사용된다) useRecoilValueLoadable
훅을 사용해서 state에 따른 분기처리를 해줘야한다.
Suspense
+ ErrorBoundary
는 설명이 좀 복잡해질 것 같아서, 여기서는 Loadable
을 사용해본다. useRecoilValueLoadable
은 recoil에서 정의한 Loadable
인스턴스를 반환해주며, Loadable
인스턴스는 아래와 같은 프로퍼티들을 가지고 있다.
훅을 사용한 뒤, 반환된 state
값에 맞춰서 상황에 알맞은 것을 렌더링해주면 된다.
const UserList = (props: {
onSelectUser: (userId: number) => void;
}): JSX.Element => {
const { onSelectUser } = props;
const { state, contents } = useRecoilValueLoadable(userListAtom);
if (state === "hasError") {
return <div>Error : {contents.message}</div>;
}
if (state === "loading") {
return <div>Loading...</div>;
}
return (
<ul>
{_.map(contents, (user) => {
return (
<li
key={user.id}
onClick={() => onSelectUser(user.id)}
style={{ cursor: "pointer" }}
>
{user.id} : {user.name}
</li>
);
})}
</ul>
);
};
선택된 사용자 카드도 비슷한 방법으로 렌더링해준다.
const SelectedUserCard = (props: { userId: number }): JSX.Element | null => {
const { userId } = props;
const { state, contents: user } = useRecoilValueLoadable(userAtom(userId));
if (!userId) {
return <div>Please select user </div>;
}
if (state === "loading") {
return <div>Loading...</div>;
}
if (state === "hasError") {
return null;
}
return (
<>
<div>
Selected User = {user.name}
<ul>
<li>{user.username}</li>
<li>{user.email}</li>
<li>
<span role="img" aria-label="phone">
📞
</span>{" "}
{user.phone}
</li>
</ul>
</div>
</>
);
};
그리고 두 컴포넌트를 사용하는 메인 앱. useState
로 상태값을 정의했는데, 이 부분도 recoil로 정의해도 무방하다.
export default function App(): JSX.Element {
const [selectedUserId, setSelectedUserId] = useState(0);
return (
<RecoilRoot>
<UserList onSelectUser={setSelectedUserId} />
<SelectedUserCard userId={selectedUserId} />
</RecoilRoot>
);
}
async
prefix나 postfix를 붙여주면 잘 구분할 수 있지 않을까 생각이 듬