
혹시 이런 식으로 사용하고 싶어서 검색하고 오셨나요?
useQuery({
queryKey: ["user"],
queryFn: fetchUser,
onSuccess: (data) => {
reset(data); // 폼 초기값 설정
},
});
react-query v5에서는 onSuccess, onError 콜백이 사라졌습니다.
왜 그런지, 그리고 어떻게 대체할 수 있는지 알아보겠습니다.
1. 왜 v5에서 useQuery의 onSuccess가 없어졌을까?
Dominic의 x 글 : https://x.com/TkDodo/status/1647341498227097600
위 x글에서 시작된 여러 스레드를 보면 핵심은 다음과 같습니다.
1. useQuery의 onSuccess와 같은 콜백은 의도된 방식으로 동작하지 않을 수 있습니다.
별도의 컴포넌트에서 두 번 호출하는 경우, api는 1번만 호출되지만 onSuccess는 두 번 호출됩니다.
// ComponentA.tsx
function ComponentA() {
const { data } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
onSuccess: () => console.log("ComponentA onSuccess"), // 호출됨
});
return <div>{data?.name}</div>;
}
// ComponentB.tsx
function ComponentB() {
const { data } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
onSuccess: () => console.log("ComponentB onSuccess"), // 이것도 호출됨
});
return <div>{data?.email}</div>;
}
// App.tsx
function App() {
return (
<>
<ComponentA />
<ComponentB />
</>
);
}
// 결과:
// fetchUser API 호출: 1번 (캐시 공유)
// onSuccess 호출: 2번 (각 컴포넌트마다 실행)
이는 onError, onSettled와 같은 콜백도 동일하게 발생합니다.
2. 비즈니스 로직을 React의 라이프사이클과 분리하기 위해
onSuccess, onError 같은 콜백을 사용하면 비즈니스 로직이 useQuery 내부에 묶이게 됩니다.
// ❌ 비즈니스 로직이 useQuery에 묶여있음
function UserProfile() {
const { data } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
onSuccess: (user) => {
// 전역 상태 업데이트
setGlobalUser(user);
},
});
return <div>{data?.name}</div>;
}
React Query는 “데이터 패칭 라이브러리”일 뿐이므로, 상태 관리나 UI 업데이트 같은 작업을 React의 다른 적절한 위치에서 처리하는 것을 권장합니다.
Dominic이라는 개발자의 개발 철학을 엿볼수 있는 부분입니다.
이외에 리렌더링 및 동기화 문제, 가독성 문제 등이 있습니다.
2. 그래서 어떻게 해결하면 좋을까요?
사실 방법은 모두가 알고 있습니다.
네, useEffect를 사용하면 됩니다.
onSuccess의 경우는 아래와 같이 query.data를 확인하면 됩니다.
// onSuccess를 useEffect로 대체
const query = useQuery({
queryKey: ["user"],
queryFn: fetchUserData,
});
useEffect(() => {
if (query.data) {
console.log("[onSuccess] :", query.data);
}
}, [query.data]);
onError도 아래와 같은 방식으로 query.error를 확인하면 됩니다.
// onError를 useEffect로 대체
export function useTodos() {
const query = useQuery({
queryKey: ["todos", "list"],
queryFn: fetchTodos,
});
React.useEffect(() => {
if (query.error) {
console.error("[onError] :", query.error);
}
}, [query.error]);
return query;
}
3. useQueryEffects
그럼 이제 useEffect를 계속 사용할 수 없으니, 훅을 하나 만들어서 사용하겠습니다.
물론 사용하는 상황에 따라 더 개선할 수 있으니 참고하는 정도로 사용하면 좋겠습니다 :)
아래는 useQueryEffects 훅의 구현입니다.
import { useEffect, useRef } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
type QueryEffectsOptions<TData, TError> = {
onSuccess?: (data: TData) => void;
onError?: (error: TError) => void;
onSettled?: (data: TData | undefined, error: TError | null) => void;
};
/**
* 쿼리 결과에 따른 사이드 이펙트를 관리하는 커스텀 훅
* @param query - React Query의 useQuery 결과
* @param options - 쿼리 상태에 반응할 콜백 함수들
* @returns 원본 쿼리 객체를 그대로 반환
*/
export function useQueryEffects<TData, TError>(
query: UseQueryResult<TData, TError>,
options: QueryEffectsOptions<TData, TError>,
) {
const { onSuccess, onError, onSettled } = options;
// 이전 상태를 추적하기 위한 ref
const prevStateRef = useRef({
isSuccess: false,
isError: false,
data: undefined as TData | undefined,
error: null as TError | null,
});
useEffect(() => {
const { isSuccess, isError, data, error } = query;
const prevState = prevStateRef.current;
// 성공 상태 확인 및 콜백 실행 (새로운 성공 상태일 때만)
if (isSuccess && onSuccess && !prevState.isSuccess) {
onSuccess(data as TData);
}
// 에러 상태 확인 및 콜백 실행 (새로운 에러 상태일 때만)
if (isError && onError && !prevState.isError) {
onError(error as TError);
}
// 완료 상태(성공 또는 에러) 확인 및 콜백 실행 (새로운 완료 상태일 때만)
if (
(isSuccess || isError) &&
onSettled &&
!(prevState.isSuccess || prevState.isError)
) {
onSettled(data, error);
}
// 현재 상태 저장
prevStateRef.current = { isSuccess, isError, data, error };
}, [
query.isSuccess,
query.isError,
query.data,
query.error,
onSuccess,
onError,
onSettled,
]);
return query;
}
마지막으로 사용 예시를 확인해보겠습니다.
const query = useQuery({
queryKey: ["user"],
queryFn: fetchUserData,
});
useQueryEffects(query, {
onSuccess: (data) => {
console.log("[onSuccess]:", data);
},
onError: (error) => {
console.error("[onError]:", error);
},
onSettled: (data, error) => {
console.log("[onSettled]:", data, error);
},
});
4. 다른 대안: Suspense와 useSuspenseQuery
위와 같이 useQueryEffects 훅을 구현하여 onSuccess를 대체할 수 있습니다.
하지만 개인적으로는 React Query가 서버 상태를 보여주는 것에 집중하고, 클라이언트 상태와 분리되는 게 맞다고 생각합니다.
useEffect로 무언가 행동을 취하는 것보다, 가능하다면 컴포넌트를 통해 선언적으로 핸들링하는 방향이 더 React스럽습니다.
// ❌ useEffect로 폼 초기값 동기화
function EditProfile() {
const { data, isLoading } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
});
const { register, reset } = useForm();
useEffect(() => {
if (data) {
reset(data); // 데이터가 오면 폼 초기화
}
}, [data, reset]);
if (isLoading) return <Loading />;
return (
<form>
<input {...register("name")} />
<input {...register("email")} />
</form>
);
}
// ✅ Suspense + useSuspenseQuery로 선언적 처리
function EditProfilePage() {
return (
<Suspense fallback={<Loading />}>
<EditProfile />
</Suspense>
);
}
function EditProfile() {
const { data } = useSuspenseQuery({
queryKey: ["user"],
queryFn: fetchUser,
});
// data는 항상 존재함이 보장됨
const { register } = useForm({
defaultValues: data,
});
return (
<form>
<input {...register("name")} />
<input {...register("email")} />
</form>
);
}
useSuspenseQuery를 사용하면 데이터가 준비된 상태에서 컴포넌트가 렌더링되므로, useEffect 없이 defaultValues에 바로 넣어줄 수 있습니다.
서버 상태는 React Query가 관리하고, UI는 컴포넌트가 선언적으로 표현하는 방식이 더 깔끔하다고 생각합니다.