blog

useSuspenseInfiniteQuery가 폼 입력값을 날려버린 이유

Front-end / React#TanStack Query#Suspense#React Hook Form#트러블슈팅

문제 상황

폼에서 지역 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

항목useSuspenseInfiniteQueryuseInfiniteQuery
로딩 처리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·경계 위치를 함께 고려해야 한다.

참고