blog
mutation 팩토리에 다중·동적 쿼리 무효화를 추가한 과정
Front-end / React#TanStack Query#useMutation#리팩터링#TypeScript
시작: 왜 이 훅들은 팩토리를 안 썼을까
프로젝트에는 mutation 훅을 만드는 공용 팩토리 createApiMutation이 있었다.
export const useChangePartStatus = createApiMutation({
mutationFn: changePartStatus,
successTitle: '상태 변경 완료',
successDescription: '상태가 변경되었습니다.',
errorTitle: '상태 변경 실패',
invalidateKey: ASSETS_KEY, // 당시 시그니처
});
그런데 코드 항목 관리 훅들(등록·수정·삭제)은 팩토리를 쓰지 않고, 훅마다 거의 같은 코드를 반복하고 있었다.
export const useCreateCodeItem = (onSuccess?: () => void) => {
const { toast } = useToast();
const invalidateQueries = useInvalidateQueries();
return useCustomApiMutation(createCodeItem, {
onSuccess: ({ data }, { groupSeq }) => {
if (data.code === CustomStatusCode.FAIL) {
// 에러 토스트
return;
}
invalidateQueries(queryKeys.codeItems(groupSeq));
invalidateQueries(queryKeys.codeGroupList());
onSuccess?.();
toast({ title: '등록 완료' });
},
});
};
// 수정·삭제 훅도 거의 같은 코드
원인: 팩토리가 "고정된 키 하나"만 받았다
type ApiMutationConfig<TData, TVariables> = {
mutationFn: (vars: TVariables) => Promise<AxiosResponse<CustomData<TData>>>;
successTitle: string;
successDescription: string;
errorTitle: string;
invalidateKey?: readonly unknown[]; // 정적 키 하나
};
코드 항목을 바꾸면 두 쿼리를 함께 갱신해야 했다.
codeItems(groupSeq): 오른쪽 코드 목록codeGroupList(): 왼쪽 탭의 그룹별 개수(codeCount)
게다가 1번은 호출할 때 넘기는 groupSeq 에 따라 달라진다. 정적 배열 하나로는 "여러 키"와 "호출 시점의 값"을 둘 다 표현할 수 없었다.
설계: 값 대신 함수로 받기
무효화할 키 목록을 값이 아니라 계산하는 함수로 받도록 바꿨다.
type ApiMutationConfig<TData, TVariables> = {
// ...
invalidateKeys?: (
data: CustomData<TData>,
variables: TVariables,
) => readonly (readonly unknown[])[];
};
함수는 훅을 정의할 때 만들어지지만, 실제로는 mutation이 성공한 뒤 호출된다. 그 시점에는 variables가 확정되어 있으므로 codeItems(groupSeq) 같은 동적 키를 만들 수 있다.
// 팩토리 내부
onSuccess: async ({ data }, variables) => {
if (data.code === CustomStatusCode.FAIL) {
// 에러 토스트
return;
}
const keys = invalidateKeys?.(data, variables) ?? [];
await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })));
onSuccess?.(data);
toast({ title: successTitle, description: successDescription, variant: 'success' });
},
invalidateQueries를 await하면 화면에 보이는 쿼리의 리패치가 끝난 뒤에 성공 토스트가 뜬다. 목록이 갱신되기 전에 "완료"가 먼저 보이는 어색함을 줄일 수 있다. 대신 리패치가 느리면 토스트도 늦어지므로, 즉시 피드백이 중요하다면 await 없이 실행만 걸어 두는 선택도 있다.
결과
const invalidateCodeKeys = (_: unknown, { groupSeq }: { groupSeq: number }) => [
queryKeys.codeItems(groupSeq),
queryKeys.codeGroupList(),
];
export const useCreateCodeItem = createApiMutation({
mutationFn: ({ groupSeq, body }: { groupSeq: number; body: CreateCodeItemRequest }) =>
createCodeItem(groupSeq, body),
successTitle: '등록 완료',
successDescription: '코드가 등록되었습니다.',
errorTitle: '등록 실패',
invalidateKeys: invalidateCodeKeys,
});
// 수정·삭제 훅도 invalidateCodeKeys 재사용
고정 키만 쓰던 기존 훅은 함수로 감싸기만 하면 된다.
invalidateKeys: () => [ASSETS_KEY],
기존 invalidateKey와 새 invalidateKeys를 함께 두지 않고 새 옵션으로 완전히 대체했다. 같은 일을 하는 옵션이 두 개면 "이럴 땐 무엇을 쓰지?"라는 고민이 생기고, 둘 다 넘겼을 때의 동작도 정의해야 한다.
정리
- 정적 키 하나(
invalidateKey: readonly unknown[])를 함수(invalidateKeys: (data, variables) => keys[])로 바꿔 다중 키와 동적 키를 모두 표현했다. - 공용 팩토리가 필요한 기능을 지원하지 않을 때, 바로 우회 구현을 하기보다 팩토리를 확장할 수 있는지 먼저 본다.
- 확장할 때는 옵션을 늘리기보다 기존 옵션을 더 일반적인 형태로 대체해서 API 표면을 작게 유지한다.