React transition과 useDeferredValue
상품 1만 개를 거르는 검색 화면입니다.
function ProductSearch({ products }) {
const [query, setQuery] = useState("");
const visible = products.filter((p) => p.name.includes(query));
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductTable rows={visible} />
</>
);
}ProductTable 을 그리는 데 시간이 걸리는 기기에서는 글자를 칠 때마다 입력칸이 굳습니다. 「노트북」을 치면 「ㄴ」이 한참 뒤에 나타나고, 그사이 친 글자들이 한꺼번에 들어옵니다. 목록이 늦게 바뀌는 것은 참을 만한데, 입력칸이 늦는 것은 고장 난 것처럼 느껴집니다.
transition 은 업데이트를 늦추는 도구라는 생각
이런 화면에서 흔히 떠올리는 해법은 디바운스입니다. 사용자가 입력을 멈춘 뒤 300ms 쯤 기다렸다가 목록을 갱신합니다. 그래서 startTransition 을 처음 보면 「React 에 내장된 디바운스」, 업데이트를 조금 늦게 처리하는 도구로 이해하기 쉽습니다.
이 이해는 두 군데서 어긋납니다. transition 에는 기다리는 시간이 없고, 늦게 처리되는 것이 아니라 다른 업데이트에 밀려날 수 있게 처리됩니다.
긴급한 업데이트와 밀려나도 되는 업데이트
화면의 업데이트를 두 종류로 나눠 보면 이해가 쉽습니다. 입력칸에 글자가 나타나는 것, 버튼이 눌린 느낌을 주는 것은 바로 일어나야 합니다. 목록을 새로 거르고 표를 다시 그리는 것은 조금 늦어도 괜찮고, 중간 결과를 건너뛰어도 됩니다.
React 는 기본적으로 모든 state 업데이트를 긴급한 것으로 처리합니다. 처음 코드에서는 setQuery 한 번이 입력칸과 무거운 표를 함께 다시 그리게 하고, 표가 끝날 때까지 다음 글자도 기다립니다.
useTransition 문서는 transition 을 이렇게 소개합니다.
useTransition is a React Hook that lets you render a part of the UI in the background.
그리고 핵심 성질을 주의 사항에 적습니다.
A state update marked as a Transition will be interrupted by other state updates. For example, if you update a chart component inside a Transition, but then start typing into an input while the chart is in the middle of a re-render, React will restart the rendering work on the chart component after handling the input update.
transition 으로 표시한 업데이트는 중단될 수 있습니다. 표를 그리는 도중에 새 글자가 들어오면 React 는 표 그리기를 멈추고 입력을 먼저 처리한 뒤, 최신 검색어로 표를 처음부터 다시 그립니다. 끝나지 않을 중간 결과를 끝까지 그리느라 입력을 막지 않습니다.
입력칸 state 는 transition 에 넣을 수 없다
그렇다면 setQuery 를 transition 으로 감싸면 될까요. 문서는 이 경우를 따로 막습니다.
Transition updates can't be used to control text inputs.
이유도 적혀 있습니다. transition 은 막지 않는(non-blocking) 업데이트인데, 입력칸의 값은 입력 이벤트에 맞춰 동기적으로 바뀌어야 합니다. 입력칸의 값이 밀려나면 커서가 튀고 글자가 사라집니다.
그래서 state 를 둘로 나눕니다. 입력칸에 보이는 값은 긴급하게, 목록을 거르는 값은 transition 으로 바꿉니다.
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setText(e.target.value); // 긴급: 입력칸
startTransition(() => setQuery(e.target.value)); // 밀려나도 됨: 목록
}
const visible = useMemo(
() => products.filter((p) => p.name.includes(query)),
[products, query],
);
return (
<>
<input value={text} onChange={handleChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ProductTable rows={visible} />
</div>
</>
);여기서 ProductTable 은 memo 로 감싸 둡니다. 긴급한 setText 로 부모가 다시 렌더링될 때 visible 이 같은 배열이면 표 렌더링을 건너뛰게 하려는 것입니다. 이것이 없으면 입력칸을 갱신하는 긴급한 렌더링에서도 표를 다시 그리게 되어 나눈 의미가 사라집니다.
isPending 은 transition 이 아직 끝나지 않았는지 알려 줍니다. 목록을 흐리게 해 「바뀌는 중」이라는 신호를 줄 수 있습니다.
같은 일을 값 하나로 처리하는 방법이 useDeferredValue 입니다. const deferredQuery = useDeferredValue(query) 로 받은 값으로 목록을 그리면, React 가 입력칸을 먼저 갱신하고 목록은 뒤따라 갱신합니다. state 를 직접 바꾸는 쪽을 제어할 수 있으면 useTransition, 값만 받아서 쓰는 쪽이면 useDeferredValue 가 맞다고 봅니다.
디바운스와 다른 점
useDeferredValue 문서는 디바운스, 스로틀과의 차이를 따로 설명합니다. 디바운스는 사용자가 입력을 멈출 때까지 기다리고, 스로틀은 일정 간격으로만 갱신합니다. 문서는 useDeferredValue 에 고정된 지연이 없다고 적습니다. 원래 렌더링이 끝나면 곧바로 새 값으로 백그라운드 렌더링을 시작하고, 새 입력이 오면 그 렌더링을 중단합니다.
차이가 드러나는 곳은 기기 성능입니다. 디바운스의 300ms 는 빠른 기기에서도 300ms 를 기다리게 하고, 느린 기기에서는 300ms 로도 부족할 수 있습니다. transition 은 기다리는 시간을 정하지 않고, 렌더링이 끝나는 만큼 빨리 반영합니다. 문서는 이 방식이 사용자의 기기에 맞춰진다고 설명합니다.
다만 문서는 useDeferredValue 가 그 자체로 네트워크 요청 수를 줄이지는 않는다고도 적습니다. 글자마다 서버에 검색 요청을 보내는 화면이라면 요청 쪽의 디바운스는 여전히 따로 필요합니다. transition 은 렌더링을 위한 도구이고, 요청을 줄이는 도구가 아닙니다.
이미 보이던 화면을 지키는 transition
transition 의 두 번째 역할은 Suspense 글에서 짚었던 부분입니다. Suspense 경계가 한 번 내용을 보여 준 뒤에 다시 기다리게 되면, 기본적으로는 fallback 이 나타나 보던 화면을 가립니다. 그 업데이트가 transition 이었다면 React 는 새 내용이 준비될 때까지 이전 화면을 그대로 둡니다.
탭을 바꾸거나 페이지를 이동할 때 이 차이가 큽니다. 보던 글이 스켈레톤으로 바뀌었다가 새 글이 뜨는 것과, 보던 글이 잠깐 그대로 있다가 새 글로 바뀌는 것의 차이입니다. Suspense 문서가 Suspense 를 지원하는 라우터는 페이지 이동을 기본적으로 transition 으로 감쌀 것으로 기대된다고 적은 이유입니다.
비동기 작업을 감싸는 Action
지금의 React 문서 기준으로 startTransition 에는 async 함수를 넘길 수 있습니다. 문서는 transition 에 넘기는 함수를 Action 이라고 부릅니다.
startTransition(async () => {
await saveDraft(content);
startTransition(() => setSavedAt(new Date()));
});Action 이 실행되는 동안 isPending 이 true 로 유지되므로, 저장 버튼을 비활성화하는 데 쓸 수 있습니다. 서버 함수 글에서 form 의 action 으로 넘긴 서버 함수가 자동으로 transition 안에서 호출된다고 한 것이 이 구조입니다.
위 코드에서 await 뒤의 setSavedAt 을 startTransition 으로 한 번 더 감싼 데는 이유가 있습니다. 문서의 문제 해결 절에 따르면 await 이후에 일어난 state 업데이트는 transition 으로 표시되지 않습니다. 같은 이유로 startTransition 안에서 setTimeout 으로 나중에 부른 업데이트도 transition 이 아닙니다. 문서는 이것을 알려진 한계로 적고 있습니다. transition 은 넘긴 함수가 실행되는 그 순간에 일어나는 업데이트만 표시한다고 기억하면 됩니다.
어떤 업데이트를 transition 으로 둘지 고르는 기준
제가 보기에 기준이 되는 질문은 하나입니다. 이 업데이트가 조금 늦거나 중간 결과를 건너뛰어도, 사용자가 방금 한 행동의 반응이 늦었다고 느끼지 않는가.
| 업데이트 | 판단 |
|---|---|
| 입력칸의 값, 체크박스, 눌린 버튼의 모양 | 긴급 (transition 아님) |
| 입력에 따라 다시 거르는 목록, 차트, 검색 결과 | transition 또는 useDeferredValue |
| 탭 전환, 페이지 이동 | transition (이미 보이던 화면 유지) |
| 저장, 제출처럼 끝날 때까지 기다리는 작업 | Action (isPending 으로 진행 표시) |
transition 은 빠르게 만드는 도구가 아닙니다. 느린 렌더링은 여전히 느립니다. 달라지는 것은 그 느림이 사용자의 입력을 막지 않게 되는 것입니다. 렌더링 자체를 줄여야 하는 경우라면 리렌더링 글에서 다룬 구조 개선이나 Profiler 확인이 먼저라고 봅니다.
여기까지가 확실한 부분
transition 이 다른 업데이트에 중단된다는 점, 입력칸 state 에 쓸 수 없다는 점, await 와 setTimeout 이후의 업데이트가 transition 으로 표시되지 않는다는 점, Action 이라는 이름, useDeferredValue 에 고정 지연이 없고 네트워크 요청을 줄이지 않는다는 점은 react.dev 문서 기준입니다. 문서는 여러 transition 이 동시에 진행되면 지금은 함께 묶어 처리한다고도 적는데, 이것은 이후 버전에서 바뀔 수 있는 한계로 소개돼 있습니다.
함께 읽기
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.
- React Context와 리렌더링로그인한 사용자 정보와 장바구니를 앱 어디서나 쓰려고 컨텍스트 하나를 만들었다고 해 보겠습니다.