(React-Query) useMutation 로직 커스텀 훅으로 리팩토링하기
프로젝트 [리스티웨이브]를 하면서 점점 기능이 늘어남에 따라 API도 늘어나고 있다. 리액트쿼리를 이용해서 서버 상태를 동기화해주고 있는데 useMutation을 사용해 API를 호출할 때 다음과 같이 사용하고 있다.
// 삭제 뮤테이션
const deleteMutation = useMutation({
mutationFn: deleteNotice,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getAdminAllNotice] });
},
});
// 알림 보내기 뮤테이션
const sendAlarmMutation = useMutation({
mutationFn: sendNoticeAlarm,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getAdminAllNotice] });
},
});
// 비공개, 공개 옵션 수정 뮤테이션
const updatePublicMutation = useMutation({
mutationFn: updateNoticePublic,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getAdminAllNotice] });
},
});
코드를 보다 보니 중복되는 부분이 많고, 동일한 API를 다른 컴포넌트에서 사용할 때 똑같은 로직을 반복해서 적어줘야 하는 불편함이 있었다. 공통 로직을 분리하여 재사용성을 높이기 위해 리액트쿼리 useMutation 훅을 커스텀훅으로 만들어 리팩토링해 보기로 했다.
Mutation Side Effects
뮤테이션의 사이드이펙트, 즉 옵션으로는 여러 가지 옵션(optional)들이 있고, 주요 속성으로 다음과 같은 함수가 주로 사용된다.
- onMutate :
- 뮤테이션이 실행되기 전에 실행되며, 뮤테이션에서 받는 것과 동일한 변수가 전달된다. 뮤테이션 전에 실행되기 때문에 낙관적 업데이트를 할 때 이용되는 함수이다.
- onError :
- 뮤테이션이 실패하면 실행되고, 에러를 전달받을 수 있다.
- onSuccess :
- 뮤테이션이 성공하면 실행된다. 또한, 뮤테이션의 결괏값으로 data(TData)를 전달받을 수 있다.
- onSettled :
- 뮤테이션 결과에 상관없이 뮤테이션 성공 또는 실패 시 실행된다. 그러므로 뮤테이션 결과와는 상관없이 쿼리를 무효화하는 로직이 필요할 때 해당 함수에 작성한다.
이들 옵션의 타입은 리액트쿼리에서 UseMutationOptions 타입을 제공하고 있는데, UseMutationOptions을 살펴보면, MutationObserverOptions 타입을 확장한(extends) 타입임을 알 수 있다.
// UseMutationOptions 타입
interface UseMutationOptions<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown> extends Omit<MutationObserverOptions<TData, TError, TVariables, TContext>, '_defaulted' | 'variables'> {
}
MutationObserverOptions을 또 타고 올라가 보면 최종 MutationOptions 타입에서 시작된 것을 알 수 있었다.
또한, MutationOptions 타입은 TData, TError, TVariables, TContext의 제네릭 타입을 매개변수로 받고, MutationOptions 타입의 속성은 mutationKey, 위에서 나열한 주요 속성 외에 retry, gcTime 등 뮤테이션 옵션들이 들어있는 것을 확인할 수 있다.
// MutationOptions 타입
interface MutationOptions<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown> {
mutationFn?: ...
mutationKey?: ...
onMutate?: ...
onSuccess?: ...
onError?: ...
onSettled?: ...
...
}
✔️ 첫 번째 방법,
처음에는 mutationFn을 지정하고, 나머지 옵션(onError 등)을 추가할 수 있는 옵션들을 받을 수 있도록 적용해 보았다. 이때, mutationFn을 지정해 두었기 때문에 뮤테이션 옵션만 타입을 지정해 주었다.
// useMutation 옵션을 사용하기 위한 커스텀 타입
type UseMutationOptionsType<TData = unknown, TError = unknown, TVariables = unknown> = Omit<
UseMutationOptions<TData, TError, TVariables>,
'mutationFn'
>;
const useCustomMutation = (mutationOptions?: UseMutationOptionsType) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteNotice,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getApi] });
},
...mutationOptions,
});
};
이 방법도 기존에 중복된 로직을 분리하기에 괜찮았지만 쿼리를 업데이트하는 부분(invalidateQueries)이 여전히 중복되었다. 그래서 mutationFn까지 받을 수 있도록 수정해 보았다.
✔️ 두 번째 방법,
mutationFn과 나머지 옵션까지 매개변수로 받을 수 있도록 적용해서 하나의 훅으로 만들었다.
const useCustomMutation = <TData = unknown, TError = unknown, TVariables = unknown>(
mutationFn: (variables: TVariables) => Promise<TData>, // ✅ 추가
mutationOptions?: Omit<UseMutationOptions<TData, TError, TVariables>, 'mutationFn'>
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.getApi] });
},
...mutationOptions,
});
};
이렇게 개선해 보니 확실히 처음에 useMutation을 한 곳에서 여러 번 사용했을 때보다 가독성이 좋아졌다. 또한, 경우에 따라 필요한 옵션을 추가로 받아을 수 있으므로 유연성과 재사용성을 높일 수 있고, 유지보수 하기도 쉬워졌다. 최종 만들어 둔 커스텀 훅은 각각의 API를 호출하는 부분을 하나의 훅으로 만들어서 한 곳에서 불러오게끔 작성해 주었다.
// React-query의 useMutation을 wrapping해주는 Notice 관련 custom hook
const useNotice = () => {
const deleteMutation = useCustomMutation(deleteApi);
const sendAlarmMutation = useCustomMutation(sendAlarmApi);
const updateMutation = useCustomMutation(updateApi);
return {
deleteMutation,
sendAlarmMutation,
updateMutation,
};
};
// 사용처
const { deleteMutation, sendAlarmMutation, updateMutation } = useNotice();
// deleteMutation.mutate()
Trigger additional callbacks
useMutation에 정의한 콜백 외에 추가로 콜백함수를 실행하려면 순서를 염두에 두어야 한다. 추가로 받은 mutationOption에서 onSuccess 함수를 전달한다면 useMutation에서 정의해 둔 onSuccess 함수에 덮어씌워질 수 있다.
// React-query 콜백 실행 순서
useMutation({
mutationFn: addTodo,
onSuccess: (data, variables, context) => {
// I will fire first
},
...
})
mutate(todo, {
onSuccess: (data, variables, context) => {
// I will fire second!
},
...
})
그러므로 mutate를 실행할 때 필요한 콜백을 추가해야지 정상적으로 실행될 수 있다.
// 사용처
const { deleteMutation, sendAlarmMutation, updateMutation } = useNotice();
deleteMutation.mutate(id, {
onSuccess: () => alert('알림을 보냈어요.'), // ✅ mutationOptions 콜백 추가
})
빌드 시 타입 에러 해결
로컬에서는 문제가 없었지만 빌드 시 아래와 같은 타입에러가 발생했다.
The inferred type of 'useNotice' cannot be named without a reference to '../../../node_modules/@tanstack/react-query/build/modern/types'. This is likely not portable. A type annotation is necessary.
이 문제는 useNotice 훅에서 반환된 값을 TypeScript가 추론하면서 외부 모듈(../../../node_modules/...)의 경로를 참조하는 타입을 생성하려고 시도하다가 타입을 찾지 못하여 빌드 시 오류를 발생시키게 된 것이다. 빌드 오류를 해결하기 위해 useNotice 훅에서 반환된 값의 타입을 명시적으로 선언해 주었다.
useCustomMutation 훅에 마우스를 올려보면 반환값의 타입은 리액트쿼리의 UseMutationResult<TData, TError, TVariables, unknown> 타입임을 알 수 있다. 이 타입을 바탕으로 useNotice 훅의 타입을 다음과 같이 정의하니 빌드 시 타입에러가 해결되었다.
// useCustomMutation이 반환하는 타입은 UseMutationResult<TData, TError, TVariables, unknown> 타입으로,
// useNotice 훅이 반환하는 각 함수의 반환값(void), 에러, 변수 타입(number), context 타입을 정의해주었다.
type UseNoticeReturnType = {
deleteMutation: UseMutationResult<void, unknown, number, unknown>;
sendAlarmMutation: UseMutationResult<void, unknown, number, unknown>;
updateMutation: UseMutationResult<void, unknown, number, unknown>;
};
// React-query의 useMutation을 wrapping해주는 Notice 관련 custom hook
const useNotice = (): UseNoticeReturnType => {
...
};
이번 기회에 프로젝트의 모든 리액트쿼리 사용 부분에 대해서 리팩토링 해봐야겠다. 👀
참고