dev.to

React 19's useTransition Looked Simple. Then I Found a Second Bug Hiding Inside the First One

React 19의 useTransition은 단순해 보였습니다. 그런데 첫 번째 버그 안에 두 번째 버그가 숨어 있었습니다

useTransition은 폼 밖에서 상태 업데이트의 우선순위와 대기 상태를 직접 관리하는 React 19 훅입니다. 글은 입력 지연, await 이후 업데이트, Error Boundary, 비동기 요청 순서 경쟁과 빠른 연속 클릭으로 생기는 별도 버그를 코드로 설명합니다.

AI 요약

useTransition은 React 19의 네 가지 관련 훅 가운데 다른 훅이나 폼 없이 단독으로 동작하는 도구입니다. 컴포넌트 최상단에서 호출하면 [isPending, startTransition]을 반환합니다. startTransition에 넘긴 함수는 호출 즉시 동기적으로 실행됩니다. 달라지는 부분은 함수 안에서 발생한 상태 업데이트와 그 결과 렌더링의 우선순위입니다. React는 해당 업데이트를 낮은 우선순위로 표시하고, 더 긴급한 업데이트가 들어오면 작업을 중단하거나 뒤로 미룹니다.

useTransition의 계약

isPending은 startTransition이 처음 호출된 뒤 true가 됩니다. await한 작업을 포함해 해당 Transition의 모든 Action이 끝나고 결과 상태가 화면에 반영되면 false가 됩니다. useTransition에는 반환값을 상태로 저장하는 기능이나 내장 오류 상태, 호출 큐가 없습니다. 콜백이 값을 반환해도 사라집니다. 반환 결과를 보관하거나 오류 상태를 관리하는 구조가 필요하면 useActionState가 더 맞습니다.

글은 긴 상품 목록 검색을 예로 들어 startTransition이 계산 자체를 백그라운드에서 실행하는 기능이 아니라는 점을 구분합니다. 입력값을 저장하는 query는 매 키 입력마다 동기적으로 갱신합니다. 필터링에 사용할 filterQuery만 startTransition 안에서 갱신합니다.

const [query, setQuery] = useState(""); const [filterQuery, setFilterQuery] = useState(""); const [isPending, startTransition] = useTransition();

function handleChange(e) { const value = e.target.value; setQuery(value); startTransition(() => { setFilterQuery(value); }); }

이 구조에서 입력창은 즉시 반응하고 목록 렌더링은 뒤처질 수 있습니다. products.filter() 호출 자체는 여전히 일반적인 동기 JavaScript로 실행됩니다. React가 배열 순회를 잘게 나누는 것이 아니라, 그 계산을 포함한 렌더링을 언제 진행할지 결정합니다. 제어 컴포넌트의 value까지 Transition 안에 넣으면 입력이 눈에 띄게 늦어지므로 두 상태를 나눠야 합니다. 컴포넌트가 setter를 직접 소유하지 않고 부모의 query prop을 받는 상황이라면 useDeferredValue(query)로 지연된 값을 별도로 만들어야 합니다.

await 뒤에는 Transition을 다시 시작해야 합니다

startTransition(async () => { ... }) 안에서 await를 만나면 JavaScript 실행이 중단되고, await 뒤 코드는 나중에 재개됩니다. React가 동기적으로 함수가 실행되는 동안만 추적하던 Transition 표시도 사라집니다. 따라서 await 뒤에 Transition에 포함할 상태 업데이트가 있으면 다시 startTransition으로 감싸야 합니다.

const saved = await saveDraft(draft); startTransition(() => { setStatus("saved"); });

글은 이 동작을 React의 단순한 누락이 아니라 JavaScript의 비동기 실행 문맥을 추적하는 방식과 관련된 제약으로 설명합니다. TC39의 AsyncContext 제안이 이 간극을 메울 가능성을 다루지만, 현재 언어에 포함된 기능은 아니라고 합니다. await가 두 번 나오면 각 await 뒤의 업데이트마다 다시 진입해야 합니다.

오류 처리와 Error Boundary

useTransition은 useActionState처럼 오류를 반환 상태로 바꿔주지 않습니다. startTransition 안의 함수가 throw하면 가장 가까운 Error Boundary로 오류가 전파됩니다. 일반적인 이벤트 핸들러에서 발생한 예외와 달리 React Action 안의 throw는 Error Boundary가 처리하도록 React가 지원합니다.

Error Boundary는 useTransition을 호출하는 컴포넌트를 감싸야 합니다. 버튼 마크업 아래에 Boundary를 배치하면 훅 호출을 포함한 오류를 잡지 못합니다. 오류가 발생하면 Boundary가 감싼 영역 전체가 fallback으로 바뀌므로, 작은 인라인 오류 메시지만 보여주려면 예외를 던지는 대신 오류 값을 직접 상태에 저장해야 합니다. 이 경우 useActionState가 제공하는 패턴을 useTransition에서 직접 다시 구성하게 됩니다.

비동기 요청 순서와 두 번째 버그

글의 가장 큰 사례는 수량 조절 버튼입니다. 사용자가 첫 요청이 끝나기 전에 다시 클릭하면 두 요청이 동시에 진행됩니다. 먼저 보낸 요청의 응답이 나중에 도착하면 오래된 값이 마지막으로 setQty를 호출해 최신 화면을 덮어쓸 수 있습니다. raw useTransition은 await 이후 Action의 실행 순서를 보장하지 않습니다. useActionState의 dispatcher는 호출을 순서대로 큐에 넣지만, useTransition에는 그런 보호 장치가 없습니다.

여기에는 별도의 버그가 하나 더 있습니다. updateQty(qty + 1) 같은 코드는 클릭 핸들러를 만든 렌더링 시점의 qty를 읽습니다. 네트워크 응답이 도착해야 setQty가 실행되는 구조에서 + 버튼을 빠르게 두 번 누르면 두 클릭이 같은 qty를 기준으로 같은 다음 값을 계산합니다. React가 두 클릭을 같은 틱으로 합쳐서 생기는 문제가 아닙니다. 상태를 실제로 바꾸는 응답이 아직 도착하지 않았기 때문에 두 이벤트가 같은 값을 읽는 문제입니다.

해결책도 두 가지로 나눕니다. requestedQty ref는 렌더링된 qty와 별개로 요청할 다음 값을 즉시 누적합니다. latestRequestId ref는 요청마다 번호를 부여하고, 응답이 도착했을 때 가장 최신 요청인지 확인합니다.

requestedQty.current += delta; const next = requestedQty.current; const requestId = ++latestRequestId.current;

const saved = await updateCartQuantity(itemId, next); startTransition(() => { if (requestId === latestRequestId.current) { setQty(saved); } });

두 ref는 렌더링을 발생시킬 필요 없이 렌더 사이에서 최신 값을 유지해야 하므로 사용합니다. AbortController로 이전 요청을 취소하는 방법도 있지만, 클라이언트의 대기만 멈출 뿐 서버가 이미 변경 작업을 처리했다면 서버 실행까지 되돌리지는 않습니다. 요청 번호 검사는 API의 취소 기능 없이도 오래된 응답이 UI를 덮어쓰는 일을 막습니다.

다만 requestedQty는 서버가 전달받은 값을 그대로 수용한다고 가정합니다. 서버가 값을 제한하거나 정규화하면 ref와 화면 상태가 어긋날 수 있습니다. 요청 번호 검사는 어느 응답을 화면에 적용할지 정할 뿐이며, 이전 요청을 취소하거나 서버가 요청을 전송 순서대로 처리하도록 보장하지는 않습니다.

선택 기준과 React 19.3의 관련 변경

폼 제출이 아니면서 대기 상태가 필요하고, 결과 값을 별도로 추적하지 않아도 되는 필터링, 정렬, 탭 전환, 토글, 데이터 로딩 후 모달 열기에는 useTransition을 사용합니다. 제어 입력의 값 자체에는 사용하지 않습니다. 함수 반환값을 상태로 관리해야 할 때도 useActionState가 맞습니다. 비동기 요청의 순서가 자동으로 보호된다고 가정해서도 안 됩니다.

글은 React 19.2.8에서 예제를 테스트했고, race condition, Error Boundary, 겹치는 Transition 검사를 9월 9일 출시된 React 19.3.0에서도 다시 실행했다고 밝힙니다. React 19.3에서는 느린 렌더링을 가진 Transition이 관련 없는 다른 Transition을 붙잡지 않도록 동작이 바뀌었습니다. 다만 여러 Transition이 동시에 진행될 때 React가 현재 이를 함께 배치하는 제한은 여전히 문서에 남아 있으므로, 여러 pending 표시가 서로 독립적으로 끝난다고 가정하면 안 됩니다.

원문: dev.to / 번역·요약: Trawling