useSuspenseInfiniteQuery가 폼 입력값을 날려버린 이유
문제 상황
폼에서 지역 Select를 바꿀 때마다 폼 전체에 로딩 스피너가 돌고, 사용자가 직접 입력한 텍스트 필드(예: 상세 위치)가 초기화되는 버그가 생겼다. 처음엔 useEffect의 reset() 호출을 의심했지만 원인은 다른 곳에 있었다.
코드 구조
<Suspense fallback={<Spinner />}> ← 부모 컴포넌트
RequestInfoTab
├── useForm({ defaultValues: requestInfo })
└── useInfiniteHospitalList({ region: selectedRegion })
└── useSuspenseInfiniteQuery(...)
지역 Select가 바뀌면 selectedRegion 상태가 바뀌고, 이 값이 무한 스크롤 목록 쿼리의 파라미터로 들어간다.
원인: Suspense 경계 아래의 상태는 함께 사라진다
useSuspense* 훅은 쿼리 key가 바뀌어 캐시에 데이터가 없으면 suspend한다. 그러면 가장 가까운 <Suspense> 경계가 fallback을 보여주고, 경계 아래 트리는 화면에서 사라진다. 이 컴포넌트가 처음 마운트되는 중이었다면 데이터가 도착한 뒤 처음부터 다시 마운트되므로 내부 상태가 남지 않는다.
1. 지역 선택 → selectedRegion 변경
2. queryKey: ['hospitals', { region: '서울' }] ← 새 key, 캐시 없음
3. useSuspenseInfiniteQuery suspend
4. 부모 Suspense가 스피너 표시, RequestInfoTab이 화면에서 사라짐
5. 데이터 도착 → RequestInfoTab 다시 마운트
6. useForm({ defaultValues: requestInfo }) 다시 초기화
7. 사용자가 입력한 값이 원래 props 값으로 돌아감
폼 상태가 useForm 안, 즉 Suspense 경계 아래에 있었기 때문에 같이 사라진 것이다.
useSuspenseInfiniteQuery vs useInfiniteQuery
| 항목 | useSuspenseInfiniteQuery | useInfiniteQuery |
|---|---|---|
| 로딩 처리 | Suspense 경계로 위임 | isPending·isFetching으로 컴포넌트 안에서 처리 |
data 타입 | 항상 정의됨 | undefined 가능 |
| 에러 처리 | Error Boundary로 위임 | isError·error로 처리 |
| key 변경 시 | suspend (경계 아래 UI 교체) | 컴포넌트 유지, 새 key의 data는 로딩 전까지 undefined |
| 컴포넌트 상태 보존 | 경계 위치에 따라 사라질 수 있음 | 유지 |
해결 1: useInfiniteQuery로 교체
import { useInfiniteQuery } from '@tanstack/react-query';
const { data, isFetching, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: queryKeys.hospitalList({ region }),
queryFn: ({ pageParam }) => fetchHospitals({ region, page: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 1,
});
컴포넌트가 계속 마운트된 상태로 남으므로 폼 입력값이 유지된다. 지역을 바꾸는 동안 이전 목록을 계속 보여주고 싶다면 placeholderData: keepPreviousData를 추가한다. 목록이 다른 지역의 값으로 잠깐 보여도 괜찮은지는 UX 맥락에 맞춰 판단한다.
다른 해결 방법
- transition으로 감싸기:
startTransition(() => setSelectedRegion(value))처럼 key를 바꾸는 업데이트를 transition으로 처리하면, 이미 보이는 화면에서는 suspend되어도 fallback 대신 기존 화면을 유지한다.useDeferredValue(selectedRegion)를 쿼리 파라미터로 써도 같은 효과다. - Suspense 경계를 좁히기: 목록을 보여주는 컴포넌트만
<Suspense>로 감싸고, 폼은 경계 밖에 둔다. - 상태를 경계 위로 올리기:
useForm을 Suspense 경계보다 위에 두고FormProvider로 내려주면, 아래 영역이 다시 마운트되어도 입력값이 남는다.
useSuspense* 훅이 잘 맞는 경우
쿼리 key가 마운트 시점에 정해지고 사용자 조작으로 바뀌지 않는 경우에 적합하다.
// 적합: 고정 조건의 무한 스크롤
useSuspenseInfiniteQuery({ queryKey: ['products', { status: 'ACTIVE' }], ... });
// 주의: 사용자 조작으로 key가 바뀜 → transition·경계 위치를 함께 설계해야 함
useSuspenseInfiniteQuery({ queryKey: ['hospitals', { region: selectedRegion }], ... });
교훈
useSuspense* 훅은 "이 컴포넌트는 데이터가 있을 때만 렌더링된다"는 보장을 준다. 그 대가로 key가 바뀔 때마다 경계 아래 UI가 교체될 수 있다. 사용자 입력처럼 지켜야 할 상태가 경계 아래에 있다면, 훅 종류·transition·경계 위치를 함께 고려해야 한다.