Suspense와 react query, jotai atomsWithQuery - boostcampwm-2022/web33-Mildo GitHub Wiki
- jotai, react-query를 사용했음에도 불구하고 useEffect를 이용하여 로딩 상태를 따로 관리했다. 로딩 상태 관리 로직과 비동기 로직이 얽혀 복잡한 코드가 작성되었다.
- MainPage를 렌더링하고,
useEffect
로 데이터를 가져온 후, 재렌더링하는 것은 비동기 요청이 병렬적으로 수행될 수 있다. 이 때문에 경쟁 상태(race conditions)에 취약할 수 있다.- 현재 MainPage에서는
useEffect
와useState
로 비동기 통신의 순서를 정해놓은 효과를 주고 있지만, 후에 비동기 통신이 추가 된다면 우리가 생각한 순서대로 데이터가 응답된다는 보장이 없어질 수도 있기 때문에 싱크가 맞지 않는 데이터를 제공할 수도 있다. - Promise.all을 통해 해결할 수 있지만 React에서 Suspense라는 더 좋은 방식을 제공한다.
- 현재 MainPage에서는
// MainPage.tsx
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 비동기 통신 로직
setIsLoading(false);
}, []);
return (
{isLoading ? (
<MapLoading />
) : (
<Map
latitude={coordinates!.latitude}
longitude={coordinates!.longitude}
/>
...
);
-
isLoading
이라는 state가 true이면 MapLoading 컴포넌트를 보여주고, false일 경우 Map 컴포넌트를 보여준다.
// App.tsx
function App() {
return (
...
<MainPage />
...
);
}
- 적용 전 App 컴포넌트 상태
// App.tsx
function App() {
return (
...
<Suspense fallback={<MapLoading />}>
<MainPage />
</Suspense>
...
);
}
- 비동기 통신을 하는 컴포넌트(여기서는
<MainPage />
)를Suspense
로 감싸주고fallback
으로 로딩 시 보여줄 컴포넌트(여기서는<MapLoading />
)를 넘겨주면 된다기에 그대로 적용해봤다. - 비동기 통신이 끝나면
<MapLoading />
대신<MainPage />
를 보여준다고 하기에 대체 Suspense가 얼마나 똑똑하면 비동기 통신을 끝내는 걸 알아서 감지하고 MainPage를 보여주는 걸까? 하는 의문이 들었다.
- 그런 생각을 하던 도중 만난 에러,
latitude
를 읽을 수 없다는 에러이다. -
latitude
는 MainPage에서 Map 컴포넌트를 렌더링하기 위해 전달하는 props이다. -
latitude
가undefined
인 것으로 보아, 비동기 통신으로 데이터를 받아오기도 전에 Map 컴포넌트에 props를 전달하려고 한 것 같다.
그럼 Suspense는 대체 뭘 보고 로딩을 해제하는가?
- 위의 궁금증을 가진 채로 [해당 블로그](https://www.daleseo.com/react-suspense/)를 참고하여 그대로 따라해봤다.
- MainPage에 너무 많은 로직이 있었기 때문에 일단 코드를 다 지우고 MainPage에서 실험해보기로 했다.
- App의 Suspense를 지우고 MainPage로 옮겼다. MainPage에 비동기 처리를 하는 Test 컴포넌트를 넣고 Suspense로 감싸줬다.
// MainPage.tsx
const fetch = () => {
let allAreas: { ok: boolean } | null = null;
const suspender = apis.getAllArea().then(data => {
allAreas = data;
});
return {
read() {
if (allAreas === null) {
throw suspender;
} else {
return allAreas;
}
}
};
};
const MainPage = () => {
return (
...
<Suspense fallback={<MapLoading />}>
<Test />
</Suspense>
);
}
-
suspender
는 api 서버에 요청한 데이터이다. 임시로 MainPage 코드와 전혀 상관없는 데이터를 받아왔다. - 왜
fetch
함수에서 굳이 클로저를 이용하여read()
함수가 있는 객체를 반환하는가?(후에 이유를 알게 된다.)
// Test.tsx
interface TestProps {
resource: {
read(): {
ok: boolean;
};
};
}
const MainPage: React.FC<TestProps> = ({ resource }) => {
const allAreas = resource.read();
return (
<>
<div>{allAreas.ok}</div>
</>
);
};
- 이렇게 실행했더니 로딩 화면이 보인 후, Test 컴포넌트가 렌더되었다.
- 성공적으로 Suspense가 적용된 것이다.
왜 되는거지?
- throw suspender는 무엇인가?
- 왜 클로저를 이용한 것인가?
위의 궁금증은 이 블로그를 보고 해결되었다.
- Suspense의 children이 Promise를 throw하면 Suspense는 fallback 프로퍼티를 렌더링한다.
- throw된 Promise가 완료(fulfilled)되면 Suspense는 children을 다시 렌더링한다.
- 여기서 Suspense의 children은 Test 컴포넌트이고 fallback 프로퍼티는 MapLoading이다.
- 즉, Test 컴포넌트가 Promise를 throw하면 MapLoading 컴포넌트를 렌더링한다. Test 컴포넌트에서 해당 Promise의 작업이 완료되면 Test 컴포넌트를 다시 렌더링한다.
-
throw suspender는 무엇인가?
- MapLoading 컴포넌트를 보여주기 위해 Promise 객체를 throw하는 것이다.
-
왜 클로저를 이용한 것인가?
const fetch = () => { let allAreas: { ok: boolean } | null = null; const suspender = apis.getAllArea().then(data => { allAreas = data; }); if (allAreas === null) { throw suspender; } else { return allAreas; } };
- 만약 이렇게 if문으로 반환했다면
allAreas
의 값을 그대로 반환한다.- 만약 데이터가 빨리 왔다면 객체를 return 했을 것이고, 느리게 왔다면 Promise를 throw했을 것이다.
- 이 결과는 fetch 함수를 한 번 호출한 이후 절대 바뀌지 않는다.
- 따라서 운 좋으면 바로 Test 컴포넌트를 보여주고, 운이 나쁘면 무한 로딩인 것이다.
- fetch()를 통해 비동기 요청을 한다면 throw suspender 또는 return allAreas이다. 만약 throw suspender로 인해 pending 상태가 유지된다면 이후 다시 fetch()가 실행되어 이전에 실행되고 있던 비동기 요청을 이어받지 못하고 새로운 비동기 요청을 하게 되어 무한 pending이 될 수 있다.
const fetch = () => { let allAreas: { ok: boolean } | null = null; const suspender = apis.getAllArea().then(data => { allAreas = data; }); return { read() { if (allAreas === null) { throw suspender; } else { return allAreas; } } }; };
- 클로저로 fetch().read()를 하게 되어 비동기 요청이 끝나지 않아 throw suspender를 하게 되면 pending 상태가 된다. 이후 fetch().read()를 통해 확인할 때 다시 비동기 요청을 하지 않고 클로저 내부에 비동기 요청을 하고 있는 suspender에 의해 다시 pending되거나 allAreas를 반환하는 동작을 해줄 수 있다.
- 만약 이렇게 if문으로 반환했다면
- 위와 같은
fetch
함수를 일일이 만들어 사용하는 것보다 이미 사용하고 있는useQuery
에 suspense를 적용해보기로 했다.
// App.tsx
function App() {
return (
<>
<QueryClientProvider client={queryClient}>
<GlobalStyle />
**<Suspense fallback={<MapLoading />}>**
<MainPage />
**</Suspense>**
</QueryClientProvider>
</>
);
}
// 실제 우리 프로젝트에서 적용해본 것
// MainPage.tsx
const { data: coordinates } = useQuery<CoordinatesTypes>(
['userCoodinates'],
() => { ... },
{
**suspense: true**
}
);
return (
// 삼항 연산자가 사라졌다.
<Map
latitude={coordinates!.latitude}
longitude={coordinates!.longitude}
/>
);
-
fetch
함수에서 suspense를 동작하게 하기 위해선throw suspender
또는return fulfilledData
형태를 만들어줘야 했다. -
useQuery
에는 이러한 형태를 만들지 않더라도 기본적으로 suspense를 지원하고 있었다. -
이전 코드에서는
useEffect
의 의존성을 이용하여 순차적으로 동작할 수 있도록 했는데, 이 과정에서 나온 코드가 상당히 보기 힘들었다. -
useEffect
를 통해 비동기 통신하는 로직을useQuery
로 변환하여 비동기 과정을 상태 관리와 분리하여 선언적으로 관리할 수 있었다.
- 전역 상태와 비동기 로직에 의존되어 있는 전역 상태는
allAreasInfo
와userInfo
이다. - 두 전역 상태는 아직 useEffect를 통해 로딩이 되고 있어 suspense를 적용하기로 했다.
- 우리 프로젝트에서는 전역 상태 관리 라이브러리로 jotai를 사용하기 때문에
jotai-tanstack-query
라는 패키지에서 제공하는atomsWithQuery
를 이용해보기로 했다. - 사용 방법은
useQuery
와 매우 유사하다.
// 우리 프로젝트에 atomsWithQuery를 적용한 코드
export const [allAreasInfoAtom] = atomsWithQuery<SortAllAreasTypes[]>(_ => ({
queryKey: ['areas'],
queryFn: async () => {
const { data: allAreas }: GetAllAreaResponseTypes = await apis.getAllArea();
return sortAllAreas;
},
}));
-
atomsWithQuery
를 적용하기 이전에는useEffect
를 이용하여 비동기 처리를 하는 로직과 atom 값을 설정해주는 로직이 복잡하게 얽혀있었다. - 사용하고 난 후에는 코드가 간결해지고 전역 상태 또한 역할이 분명해졌다.
// 비동기 데이터 쿼리를 사용하기 이전
const [value, setValue] = useAtom(allAreasInfoAtom);
useEffect(() => {
const fetchValue = async () => {
const res = await fetch(); // 비동기 요청
setValue(res.data); // 상태 업데이트
};
fetchValue();
}, []);
// 비동기 데이터 쿼리를 사용한 후
// 이 한줄에 비동기 요청과 상태 업데이트 모두 진행됨
const [value] = useAtom(allAreasInfoAtom);
-
atomsWithQuery
로 받아오는 데이터 중 유저 정보가 있었는데, 그 중 북마크 리스트를 업데이트 해야 했다. -
atomsWithQuery
에도atom
처럼 setter가 당연히 있는 줄 알고 사용하려 했다가 setter가 없다는 에러가 발생했다.
-
atomsWithMutation
에러로 고민하던 도중, 공식문서에서 사용한atomsWithQuery
와 우리가 사용한atomsWithQuery
의 차이점을 발견했다.
// 예시
import { atom, useAtom } from 'jotai'
import { atomsWithQuery } from 'jotai-tanstack-query'
const idAtom = atom(1);
const [userAtom] = atomsWithQuery((get) => ({
queryKey: ['users', get(idAtom)],
queryFn: async ({ queryKey: [, id] }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
return res.json();
},
}));
- 공식 문서에서는
get
이라는 파라미터를 받고 있었다. 이get
은 다른 atom을 인자로 받는 함수였다. -
get
이 무엇인지 공식 문서에서 제대로 설명해주지 않았지만(못 찾은 것일 수도 있다.), 실험해보니 인자로 준 atom을 setter로 변경시키면atomsWithQuery
의queryFn
이 실행되는 듯 했다. 즉, 다시 서버에 요청을 보내는 것이다. - 그렇기 때문에
atomsWithQuery
는 setter를 통해 전역상태를 바꾸는 것이 아닌queryKey
를 바꾸어 비동기 요청을 하게 되고, 이 결과로 전역 상태도 바뀌게 된다.
// 예시 코드 (정확히 동작하지 않을 수 있음, 이해를 돕기 위한 코드)
const UserData = () => {
const [id, setId] = useAtom(idAtom);
const [data] = useAtom(userAtom);
setId(2);
return <div>{JSON.stringify(data)}</div>
}
- id를
setId
로 바꾸어 주면userAtom
의queryKey
가 바뀌었기 때문에 비동기 요청을 하게 된다. 이렇게queryKey
를 업데이트하여 비동기 재요청을 할 수 있게 된다.- 하지만 다시
setId(1)
을 하게 되면 캐싱된 data를(id = 1일 경우는 이미 요청을 했다는 가정) 가져오게 된다. 옵션으로 언제까지 캐싱을 해줄지 정해줄 수 있으며 많은 옵션을 제공해주고 있기 때문에 참고하면 좋을 것 같다.
- 하지만 다시
이제 사용자 정보를 가져오는 jotai 전역 상태를 업데이트 하는 법을 알아냈으니 적용하는 일만 남았다.
export const userBookmarkAtom = atom<string[]>([]);
export const [userInfoAtom] = atomsWithQuery(get => ({
queryKey: ['users', get(userBookmarkAtom)],
queryFn: async (): Promise<GetUserResponseTypes> => {
const data = await apis.getWhetherUserLoggedIn();
return data;
}
}));
-
userInfoAtom
의 key값으로userBookmarkAtom
을 하여 북마크를 추가하거나 삭제할 때 사용자 정보를 업데이트할 수 있게 하였다.
const setBookmarkAtom = useSetAtom(userBookmarkAtom);
const [userInfo] = useAtom(userInfoAtom);
// 북마크 삭제
await apis.deleteBookmark(areaName, userId);
setBookmarkAtom(
bookmarks.filter((bookmark: string) => bookmark !== areaName)
);
// 북마크 추가
await apis.addBookmark(areaName, userId);
setBookmarkAtom(bookmarks.concat(areaName));
- 사용자의 북마크를 추가하거나 삭제하면
setBookmarkAtom
을 통해userBookmarkAtom
을 변경해주게 되고userInfoAtom
은get(userBookmarkAtom)
을 해주어 비동기 요청을 하게 된다. 이렇게 북마크가 업데이트된 사용자 정보를 다시 가져올 수 있게 된다.
- 수정 전
// SecondLevelComponent.tsx
{isGraphLoading ? (
<MapLoading ... /> // props 생략
) : (
<Chart
type='bar'
options={options}
series={series}
width='100%'
height={2500}
/>
)}
- 수정 후
// SecondLevelComponent.tsx
<Chart
type='bar'
options={options}
series={series}
width='100%'
height={2500}
/>
// InfoDetailModal.tsx
...
<Suspense fallback={<MapLoading .../>}>
<SecondLevelComponent
firstLevelInfo={firstLevelInfo}
isSecondLevel={isSecondLevel}
/>
</Suspense>
...
- 이외에도 사용자 정보를 가져오는 비동기 로직과 그 데이터를 전역 상태로 저장하는 로직이 합쳐지면서 관련된 코드를 전부 삭제할 수 있었고, 좀 더 깔끔한 코드가 작성되었다.
- 리액트 Suspense의 동작 원리 학습
- useQuery와 atomsWithQuery로 비동기 로직과 상태 관리 코드를 간결하게 작성
- useQuery와 atomsWithQuery로 Suspense 기능 사용