blog
useDeferredValue로 Suspense 검색 화면의 깜빡임과 포커스 소실 해결하기
Front-end / React#React#useDeferredValue#Suspense#TanStack Query
문제 상황
목록 조회를 TanStack Query의 useSuspenseQuery로 바꾼 뒤, 검색어를 입력할 때마다 화면 전체에 로딩 스피너가 나타났다.
- 한 글자 입력할 때마다 스피너 → 목록 → 스피너가 반복되는 깜빡임
- 스피너가 보이는 동안 검색 입력창이 언마운트되어 포커스가 사라짐
원인
Suspense 경계가 검색 입력창까지 포함한 페이지 전체를 감싸고 있었다.
<Suspense fallback={<Spinner />}> ← suspend되면 이 아래 전체가 fallback으로 대체
<ListPage>
<SearchInput /> ← 함께 사라짐 (포커스 소실)
<DataTable /> ← 실제로 데이터를 기다리는 부분
</ListPage>
</Suspense>
- 입력하면
searchWord상태가 즉시 바뀐다. - 새 query key에 캐시가 없으므로
useSuspenseQuery가 suspend한다. - 가장 가까운 Suspense 경계가 fallback(스피너)으로 바뀌고, 그 아래
SearchInput도 화면에서 사라진다. - 데이터가 오면 다시 그려진다. 이 과정이 글자마다 반복된다.
해결: useDeferredValue
// Before
const [searchWord, setSearchWord] = useState('');
const { data } = useListQuery({ searchWord }); // 입력마다 새 key → suspend → 스피너
// After
const [searchWord, setSearchWord] = useState('');
const deferredSearchWord = useDeferredValue(searchWord);
const { data } = useListQuery({ searchWord: deferredSearchWord });
// 입력창은 즉시 상태를 사용
<SearchInput value={searchWord} onChange={(e) => setSearchWord(e.target.value)} />
핵심은 React 문서에 나온 다음 동작이다. 지연된(deferred) 값으로 다시 렌더링하다가 suspend되면, React는 fallback을 보여주지 않고 이전 화면을 그대로 유지한다.
동작 흐름
"abc" 입력
1. 긴급 렌더 (searchWord = "abc", deferredSearchWord = 이전 값)
- 입력창에 "abc" 표시
- 쿼리는 이전 key 그대로 → 이전 데이터 표시
2. 백그라운드 렌더 (deferredSearchWord = "abc")
- 새 key로 useSuspenseQuery가 suspend
- React는 이 렌더를 커밋하지 않고 기존 화면 유지 (스피너 없음, 입력창 유지)
- 데이터 요청 진행
3. 데이터 도착 → 백그라운드 렌더 완료·커밋 → 새 결과 표시
지연 렌더는 언제 일어나나
useDeferredValue의 백그라운드 렌더는 고정된 지연 시간 없이 긴급 렌더가 끝난 직후 바로 시작된다. 다만 중단 가능한 렌더라서, 그 사이 새 입력이 들어오면 진행 중인 백그라운드 렌더를 버리고 최신 값으로 다시 시작한다. 빠르게 타이핑하면 중간 값의 렌더가 계속 버려지므로 결과적으로 debounce와 비슷하게 느껴진다.
단, 렌더가 버려지는 것과 요청이 취소되는 것은 다르다. 중간 값으로 렌더를 시도하는 순간 이미 그 key의 요청이 시작될 수 있으므로, API 호출 수를 확실히 줄이려면 debounce를 함께 쓴다.
갱신 중 표시
이전 결과를 보여주는 동안 "왜 안 바뀌지?"라고 느끼지 않도록, 두 값이 다를 때 흐리게 표시한다.
const isStale = searchWord !== deferredSearchWord;
<div className={cn('transition-opacity duration-200', isStale && 'opacity-50')}>
<DataTable data={data} />
</div>
useDeferredValue vs debounce
| 항목 | useDeferredValue | debounce(300ms) |
|---|---|---|
| 깜빡임·포커스 소실 방지 | ✅ | ✅ (값이 바뀔 때 한 번은 suspend될 수 있음) |
| 입력 반응성 | 즉시 | 즉시 |
| API 요청 수 감소 | △ 보장되지 않음 | ✅ 멈춘 뒤 1번 |
| 지연 시간 제어 | React가 결정 | 명시적 설정 |
debounce만 쓰면 요청 수는 줄지만, 디바운스된 값이 바뀌는 순간 여전히 suspend가 일어나 스피너가 한 번 나타난다. 둘을 함께 쓰는 것이 가장 무난하다. debounce된 값을 다시 useDeferredValue로 감싸면 요청 수와 깜빡임을 모두 잡을 수 있다.
다른 해결 방법
- Suspense 경계 좁히기: 검색 입력창은 경계 밖에 두고
DataTable만 감싸면 포커스 소실은 사라진다. 대신 목록 영역의 스피너 깜빡임은 남는다. startTransition: 검색어 상태 업데이트를 transition으로 감싸도 같은 효과(이전 화면 유지)를 얻는다. 입력창 값까지 transition으로 바뀌면 안 되므로, 입력값과 쿼리용 값을 분리해야 한다.- Suspense를 쓰지 않는 경우:
useQuery의placeholderData: keepPreviousData로 새 데이터가 올 때까지 이전 데이터를 유지할 수 있다.
정리
| 항목 | Before | After |
|---|---|---|
| 타이핑 시 스피너 | 글자마다 전체 화면 | 없음 |
| 입력창 포커스 | 매번 소실 | 유지 |
| Suspense 구조 변경 | - | 불필요 |
| 코드 변경량 | - | 훅마다 2줄 |