tanstack-query를 활용하여 서버상태를 관리하였고, API 요청 시 발생하는 에러를 핸들링하려고 하였다. Tkdodo님 블로그의 에러 핸들링편에 따르면 useQuery에서 에러가 발생할 경우 onError로 처리하면 observer마다 실행이 된다. 이러한 문제 때문에 v5에서는 onError 옵션이 제거되긴 했지만, 이전 버전을 사용하더라도 fetch error는 전역에서 잡아 처리하는 게 적절하다. 블로그 예시와 유사하게 Provider로 내려준 toast.error()를 호출하여 에러 메세지를 toast로 띄우도록 처리하였다.
문제는 한번 오류가 발생하면 그 뒤로 애플리케이션이 정상 작동하지 않았다.
처음에는 낙관적 업데이트를 처리하다가 오류로 인해 캐시가 날라가는 문제라고 생각했다. 그래서 낙관적 업데이트 로직을 모두 제거하였는데, 그래도 문제가 해결되지 않았다. 그래서 @tanstack/react-query-devtools
를 통해 캐시 동작을 확인해보니 쿼리키에 해당하는 캐시 데이터 외에 모든 캐시가 초기화
되는 것을 확인하였다 (???)
로그를 출력해 확인해보니, queryCache의 onError로 에러 처리하기 위해 toast를 QueryProvider에서 선언한 것이 리렌더링을 발생시켜 queryClient 인스턴스가 재생성
되는 것이였다. 따라서 에러가 발생할 경우 인스턴스가 재생성되어 모든 캐시가 초기화되는 문제가 발생하였다.
// QueryProvider.tsx
const QueryProvider = ({children}: PropsWithChildren) => {
const { error: showToast } = useToast();
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => showToast(error.message),
}),
})
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
// App.tsx
function App() {
return (
<ToastProvider>
<QueryProvider>
<ProductListPage />
</QueryProvider>
</ToastProvider>
);
}
문제의 핵심은 queryClient 인스턴스가 재생성
된다는 것이다. 그래서 인스턴스 재생성을 막기 위해선 new QueryClient() 가 앱이 로드될 때 한번만 실행되어야 하는데, 컴포넌트 외부에 선언하면 toast를 사용할 수 없으므로 내부에 선언하되 toast.error 함수를 호출할 때 리렌더링이 발생하면 안된다.
따라서 toast.error 함수를 useCallback으로 감싸고, QueryProvider에서는 queryClient를 useMemo로 감싸서 앱이 로드될 때 최초 한번만 할당되도록 하여 문제를 해결하였다.
// ToastProvider.tsx
const ToastProvider = ({ children }: PropsWithChildren) => {
const [toastMessage, setToastMessage] = useState('');
const timerRef = useRef<NodeJS.Timeout | null>(null);
const error = useCallback((message: string) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setToastMessage('');
}, 3000);
setToastMessage(message);
}, []);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, []);
return (
<ToastContext.Provider value={{ error }}>
{children}
{toastMessage &&
createPortal(<S.ToastContainer>{toastMessage}</S.ToastContainer>, document.body)}
</ToastContext.Provider>
);
};
// QueryProvider.tsx
const QueryProvider = ({ children }: PropsWithChildren) => {
const { error: showToast } = useToast();
const queryClient = useMemo(
() =>
new QueryClient({
queryCache: new QueryCache({
onError: (error) => showToast(error.message),
}),
}),
[showToast],
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
나는 toast UI만 띄우는 방식을 택해서 ErrorBoundary를 사용하지 않았는데, query 또는 mutation이 에러가 발생했을 때 에러 UI
를 보여주고 싶을 수 있다. isError로 분기처리할 수도 있지만 가독성과 관심사 분리 측면
에서 ErrorBoundary에게 이 역할을 맡기는 게 적절하다고 판단할 것이다.
하지만 비동기 에러는 런타임에 발생하지 않기 때문에 Errorboundary에서 잡지 못하는데, query에서 비동기 에러를 잡고 이를 throw하여 다음 렌더링 사이클에 에러 대체 UI를 보여줄 수 있다. 이때 throwOnError: true
를 쿼리 옵션으로 추가하여 Reject된 Promise를 반환하도록 처리할 수 있다.
return useQuery({
queryKey: [...],
queryFn: ...,
throwOnError: true,
...
});
공식문서의 throwOnError 설명을 보면 render 단계에서 에러를 throw하여 ErrorBoundary에서 잡을 수 있도록 처리하는 것을 알 수 있다.
throwOnError : Set this to true if you want errors to be thrown in the render phase and propagate to the nearest error boundary
https://tkdodo.eu/blog/react-query-error-handling
https://tanstack.com/query/latest/docs/framework/react/reference/useQuery