react-query
의 useIsMutating
hook을 활용해 전역으로 mutation 상태를 처리해보자
짤로 요약
카드를 등록(POST)
할 때 상황이다.
버튼을 클릭하면 카드 등록 API를 호출하고 성공하면 닫히며 목록에 생성한 카드가 식별되는 형태이다.
네트워크 속도가 항상 매우 빠른 상태로 유지되거나 서버가 항상 일정한 빠른 속도로 응답을 해줄 것이란 보장이 없기 때문에 위와 같은 상황에서는 의도하는 동작이 1회 수행되게끔 보장해줘야 하는 것 같다.
사실 무언가 상호작용 했을 때 조회된다던가 수정하는 동작에서는 네트워크 지연 또는 서버 응답 문제로 여러번 호출되어도 결과가 같기 때문에 불필요한 호출을 하는 문제는 있더라도 보여지는 동작에서는 문제 없을 것이다.
하지만 POST
와 같은 동작은 내용물이 같을 뿐 다른 고유 식별자를 가지는 새로운 자원이 호출한 만큼 생성되는 형태이기 때문에 서버에도 불필요한 자원이 중복으로 생성되고 사용자에게도 불편함을 주는 문제가 발생하는 것 같다.
따라서 여러 api를 호출하여 결과를 봐야 한다던가 응답속도가 느릴 확률이 높다면 최소한 멱등하지 않은 동작에 대해서는 의도한대로만 동작하도록 처리를 해주면 좋을 것 같다는 생각이 들었다.
사실 위의 문제는 react-query
를 사용하던 안하던 상관없이 발생할 수 있는 문제이다.
본 상황에서는 쿼리 무효화
나 낙관적 업데이트
등 다양한 동작 수행과 에러처리, 테스트의 효율성 때문에 등록/수정/삭제
동작에는 모두 useMutation
훅을 사용하여 처리했었다.
따라서 react-query
에서 제공하는 mutation
관련 상태 hook을 사용해 이 문제를 처리해보기로 했다.
호출하는 동안 버튼을 disabled로 만든다던가 여러 방법이 있겠지만 버튼은 api 호출과 관련된 동작만 수행할 뿐 아니라 다양한 방식으로 사용되기 때문에 건드리다가 문제가 발생할 가능성이 높아보였고
다른 부분에서도 재사용할 곳이 많을 것 같다는 생각이 들어 전체화면 로딩 스피너 형태로 업데이트 중 UI 처리를 구현해보기로 했다.
useIsMutating is an optional hook that returns the number of mutations that your application is fetching (useful for app-wide loading indicators).
리액트 쿼리에서는 useIsMutating
과 useIsFetching
같은 편의 hook을 제공해준다.
useIsMutating
은 현재 어플리케이션의 리액트 쿼리 인스턴스에서 발생하고 있는 mutation
의 개수를 나타내주는 hook이다.
import { useIsMutating } from '@tanstack/react-query'
// How many mutations are fetching?
const isMutating = useIsMutating()
// How many mutations matching the posts prefix are fetching?
const isMutatingPosts = useIsMutating({ mutationKey: ['posts'] })
이 훅을 통해 기존의 useMutation
동작에 추가적인 코드를 작성하거나 설정하지 않아도 mutation
상태를 별도로 감지하고 관리할 수 있게 된다.
const GlobalLoadingSpinner = () => {
const isMutating = useIsMutating();
return (
<GlobalLoadingSpinnerWrapper isVisible={!!isMutating}>
<LoadSpinner width="100%" height="100%" />
</GlobalLoadingSpinnerWrapper>
);
};
글로벌 로딩 스피너 컴포넌트를 앱 전역에 걸쳐 동작하도록 구성하고 isMutating
일 때 보여지게끔 하였다.
근데 사실 특정 동작을 수행중일 때 전체 화면으로 로딩중
UI가 식별되는 것 그 자체가 올바른지는 잘 모르겠다.
사용자가 요청한 것이 정상적으로 수행중임을 알릴 수 있는 수단이 될 수는 있지만 너무 자주나오면 오히려 산만하고 집중력을 떨어뜨릴 수 있다고 생각이 되기 때문이다.
그리고 이렇게 명확하게 로딩 스피너가 필요없는 경우도 있다.
useIsMutating
훅의 첫번째 파라미터 객체로 mutationKey
를 받을 수 있다.
기본 값은 undefined
이며
해당 객체의 값에 등록된 키가 없다면(undefined) 전부, 있다면 해당 키가 포함된 mutation
들만 useIsMutating
으로 감지한다.
mutationKey
mutation
의 옵셔널한 설정으로 추가할 수 있다.
내부적으로 키를 설정하지 않았을 때 어떻게 설정되서 동작했나 확인하고 싶었는데 아직까진 방법을 모르겠다.
queryClient
의 MutationCache
를 확인해봐도 따로 지정하지 않으면 mutationKey
속성이 없다.
A mutation key can be set to inherit defaults set with queryClient.setMutationDefaults or to identify the mutation in the devtools
문서에도 이 설명이 다라서 애매하나 기본 mutation key를 잘 설정해서 사용한다면 원하는 mutation
만 필터링해서 useIsMutating
에서 감지해 로딩 처리를 할 수 있을 것이다.
const isMutating = useIsMutating({ mutationKey: ['key'] });
위에서 언급했던대로 수정할 때 마다 로딩스피너가 식별된다면 굉장히 산만할 것이다.
특히 일반적으로 수정,삭제와 같은 api에 대한 응답이 100ms 내외로 완료되는데
이 짧은시간동안 로딩스피너를 보여준다면 오히려 이로 인해 불필요한 점멸 효과를 유발하여 사용자 경험에 좋지 않을 것이다.
const GlobalLoadingSpinner = () => {
const { isGlobalLoading } = useGlobalLoading();
const isMutating = useIsMutating({ mutationKey: ['default'], exact: true });
const isLoading = !!(isMutating || isGlobalLoading);
const [isDeferred, setIsDeferred] = useState(false);
const debouncedSetDeferred = useDebounce(setIsDeferred, 200);
useEffect(() => {
if (isLoading) {
debouncedSetDeferred(true);
return () => {
debouncedSetDeferred(false);
};
}
}, [isLoading]);
return (
<GlobalLoadingSpinnerWrapper isDeferred={isDeferred} isVisible={isLoading}>
<LoadSpinner width="100%" height="100%" />
</GlobalLoadingSpinnerWrapper>
);
};
디바운싱을 통해 200ms가 지나기 전까지는 글로벌 로딩스피너가 눈에 보이지 않도록 처리하였다.
네트워크: 제한없음 | 네트워크: 느린 3G |
opacity
로 로딩스피너가 눈에 보이지 않게만 한거라 중복호출은 쉽게 방지할 수 있으면서도 응답속도가 빠를 경우에는 불필요하게 깜빡거리지 않게 처리할 수 있다.
응답속도가 디바운싱을 적용한 시간과 비교했을 때 애매하게 느릴 경우에는 여전히 일시적인 깜빡임이 발생하겠지만 항상 깜빡거리는 것 보다는 훨씬 나을 것이라고 생각했다.
react query mutation
외에도 특정 비동기 함수의 호출이 완료되기까지 처리가 필요한 부분이 있었던 것 같다.
presigned url
을 얻어와 s3에 이미지를 업로드하는 비동기 함수가 있었는데
이 함수는 완료되기까지 시간이 일정하지도 않고 꽤 길 수 있었다.
이 함수 자체에서 loading 전역 상태를 컨트롤하기에는 애매했던게 어떤 곳에서는 글로벌 로딩 스피너가 필요했지만 또다른 곳에서는 필요없었다.
따라서 전역 로딩 상태를 변경하면서 비동기 함수를 실행시켜주는 hook을 만들어 원하는 상황에서 호출할 수 있게 하고, 기존 mutation 로딩 스피너에 붙였다.
여기서 로딩 global state 관리는 recoil
을 사용했다.
useGlobalLoading.ts
import { useRecoilState } from 'recoil';
import { isGlobalLoadingState } from '@/atoms/common';
const useGlobalLoading = () => {
const [isGlobalLoading, setIsGlobalLoading] = useRecoilState(isGlobalLoadingState);
const asyncCallbackLoader = async <R>(callback: () => Promise<R>) => {
setIsGlobalLoading(true);
try {
const response = await callback();
return response;
} catch (e) {
throw e;
} finally {
setIsGlobalLoading(false);
}
};
return { isGlobalLoading, asyncCallbackLoader };
};
export default useGlobalLoading;
특정 비동기 함수 호출 전 loading 상태를 true로 바꿔주고 호출이 완료되었을 때 false로 직접 바꿔줘야하는 동작을 wrapper 함수를 만들어 callback
을 인자로 받아 해당 hook에서 처리하게 했다.
const { asyncCallbackLoader } = useGlobalLoading();
// ...
const requestedThumbnail = await asyncCallbackLoader<string | undefined>(() => getUploadedThumbnail());
이런식으로 기존 동작에 영향을 주지 않고 똑같이 처리하면서도 로딩 상태를 처리할 수 있게 되었다.
로딩 스피너 컴포넌트에 추가해 상태를 사용할 수 있게 해주면 된다.
const GlobalLoadingSpinner = () => {
const { isGlobalLoading } = useGlobalLoading();
const isMutating = useIsMutating({ mutationKey: ['default'], exact: true, });
return (
<GlobalLoadingSpinnerWrapper isVisible={!!(isMutating || isGlobalLoading)}>
<LoadSpinner width="100%" height="100%" />
</GlobalLoadingSpinnerWrapper>
);
};
테스트 코드
jest.mock('recoil', () => ({
useRecoilState: jest.fn(),
}));
jest.mock('@/atoms/common', () => ({
isGlobalLoadingState: jest.fn(),
}));
describe('useGlobalLoading', () => {
beforeEach(() => {
(useRecoilState as jest.Mock).mockReturnValue([false, setIsGlobalLoading]);
});
afterAll(() => {
jest.clearAllMocks();
});
const setIsGlobalLoading = jest.fn();
const fakeAsyncFunction = jest.fn(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve('Success');
}, 200);
});
});
it('주어지는 비동기 함수 호출 성공 시 loading 상태가 변경된다.', async () => {
const { result } = renderHook(() => useGlobalLoading());
expect(result.current.isGlobalLoading).toBeFalsy();
await result.current.asyncCallbackLoader(() => fakeAsyncFunction());
expect(setIsGlobalLoading).toHaveBeenCalledWith(true);
expect(setIsGlobalLoading).toHaveBeenLastCalledWith(false);
});
it('주어지는 비동기 함수 호출 에러 발생시 loading 상태가 변경된다.', async () => {
const { result } = renderHook(() => useGlobalLoading());
fakeAsyncFunction.mockRejectedValue(new Error('Async error'));
expect(result.current.isGlobalLoading).toBeFalsy();
await expect(async () => {
await result.current.asyncCallbackLoader(() => fakeAsyncFunction());
}).rejects.toThrowError(new Error('Async error'));
expect(setIsGlobalLoading).toHaveBeenCalledWith(true);
expect(setIsGlobalLoading).toHaveBeenLastCalledWith(false);
});
});