본문 바로가기
SPA/React

React 19 useTransition, Actions로 비동기 pending과 에러 한 번에 처리하기

by J4J 2026. 7. 20.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 React 19에서 useTransition에 async 함수를 직접 전달하여 비동기 작업의 pending 상태와 에러를 한 번에 관리하는 방법에 대해 알아보는 시간을 가져보려고 합니다.

 

 

 

useTransition에 async 함수를 사용하면 좋은 상황

 

정렬 변경, 검색, 탭 전환처럼 버튼 클릭 한 번으로 비동기 작업이 시작되는 화면을 만들다 보면 로딩 상태와 에러를 함께 관리해야 하는 상황을 마주치게 됩니다.

 

지금까지는 이런 화면을 만들 때 로딩 여부와 에러 메시지를 각각 별도의 state로 선언하고, try/catch/finally로 직접 관리해야 했습니다.

 

제가 처음 이런 화면을 만들 때는 finally에서 로딩 state를 내려주는 코드를 깜빡해서, 요청이 끝난 뒤에도 버튼이 계속 로딩 중으로 멈춰 있는 버그를 만든 적이 있습니다.

 

// ❌ useState로 pending과 에러를 직접 관리하는 기존 방식
function SortButtons() {
  const [items, setItems] = useState<Item[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function handleSort(sort: 'asc' | 'desc') {
    setIsLoading(true)
    setError(null)

    try {
      const next = await fetchItems(sort)
      setItems(next)
    } catch {
      setError('정렬에 실패했습니다.')
    } finally {
      setIsLoading(false)
    }
  }
  // ...
}

 

이렇게 작성하면 세 블록 중 하나라도 갱신을 빠뜨릴 경우 로딩이나 에러 상태가 실제 요청 결과와 어긋나는 버그로 이어지기 쉽습니다.

 

 

 

// ✅ React 19: startTransition에 async 함수를 전달하는 방식
function SortButtons() {
  const [items, setItems] = useState<Item[]>([])
  const [isPending, startTransition] = useTransition()

  function handleSort(sort: 'asc' | 'desc') {
    startTransition(async () => {
      const next = await fetchItems(sort)
      setItems(next)
    })
  }
  // ...
}

 

그러므로 로딩 state와 에러 전파를 직접 작성하는 대신 React에 맡기고 싶다면 startTransition에 async 함수를 바로 전달하는 것이 좋습니다.

 

정리하면 다음과 같은 상황에서 useTransition에 async 함수를 사용하는 것이 좋습니다.

 

  • 버튼 클릭 등으로 시작되는 비동기 작업의 로딩 상태를 별도 state 없이 관리하고 싶은 경우
  • 비동기 작업 중 발생한 에러를 Error Boundary로 자연스럽게 위임하고 싶은 경우
  • Server Action 유무와 관계없이 클라이언트 단에서 pending을 관리하고 싶은 경우
  • useOptimistic, useActionState 같은 다른 Actions 계열 훅과 함께 사용할 기반을 만들고 싶은 경우

 

 

 

React 18에서도 startTransition에 async 함수를 전달하는 것 자체는 런타임 에러 없이 실행되지만, React가 반환된 Promise를 추적하지 않기 때문에 isPending이 첫 번째 await 지점에 도달하는 즉시 false로 바뀌어 실제 완료 시점과 어긋나게 됩니다.

 

TypeScript를 사용하는 경우에는 React 18에서 startTransition의 인자 타입이 동기 함수로 제한되어 있어 async 함수를 그대로 넘기면 컴파일 에러가 먼저 발생합니다.

 

이렇게 startTransition에 전달되어 pending, 에러, 낙관적 업데이트를 자동으로 관리하는 async 함수를 React에서는 관례적으로 Actions라고 부릅니다.

 

Server Action의 선언 방식과 useActionState, useFormStatus의 자세한 사용법은 별도로 다룬 포스팅을 참고해 주시길 바랍니다.

 

React Server Action, API Route 없이 서버 함수를 호출하는 방법

 

React Server Action, API Route 없이 서버 함수를 호출하는 방법

안녕하세요. J4J입니다. 이번 포스팅은 React Server Action이 무엇이고, 실무에서 어떻게 활용할 수 있는지에 대해 알아보는 시간을 가져보려고 합니다. Server Action이란? Se

jforj.tistory.com

 

그러면 useTransition을 실제로 어떻게 사용하는지 살펴보겠습니다.

 

 

반응형

 

 

useTransition 기본 사용법

 

useTransition은 react 패키지에서 바로 import하여 사용할 수 있습니다.

 

반환값은 정확히 2개이며 순서가 고정되어 있습니다.

 

const [isPending, startTransition] = useTransition()

 

  • isPending: 전달한 함수가 아직 진행 중인지 여부
  • startTransition: 상태 업데이트를 transition으로 표시하는 함수

 

React 19부터는 startTransition에 전달한 함수가 Promise를 반환하면, React가 그 Promise가 resolve 또는 reject되어 settle될 때까지 isPending을 true로 유지합니다.

 

 

 

실제로 정렬 버튼을 눌러 목록을 새로 불러오는 예제로 확인해 보겠습니다.

 

// pages/sort-list/fetchItems.ts
export interface Item {
  id: number
  name: string
}

export async function fetchItems(sort: 'asc' | 'desc'): Promise<Item[]> {
  const res = await fetch(`https://api.example.com/items?sort=${sort}`)
  return res.json()
}

 

// pages/sort-list/index.tsx
import { useState, useTransition } from 'react'
import { fetchItems, type Item } from './fetchItems'

export default function SortListPage() {
  const [items, setItems] = useState<Item[]>([])
  const [isPending, startTransition] = useTransition()

  function handleSort(sort: 'asc' | 'desc') {
    startTransition(async () => {
      const next = await fetchItems(sort)
      setItems(next)
    })
  }

  return (
    <div className="flex flex-col gap-4 p-6">
      <div className="flex gap-2">
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => handleSort('asc')}
        >
          오름차순
        </button>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => handleSort('desc')}
        >
          내림차순
        </button>
      </div>
      {isPending ? (
        <p className="text-sm text-gray-500">정렬하는 중입니다...</p>
      ) : (
        <ul className="flex flex-col gap-2">
          {items.map((item) => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
      )}
    </div>
  )
}

 

오름차순이나 내림차순 버튼을 누르면 fetchItems의 Promise가 settle될 때까지 isPending이 true를 유지하다가, 응답을 받은 시점에 자동으로 false로 돌아갑니다.

 

다만 isPending이 settle 시점까지 자동으로 유지되는 것과, startTransition 내부의 상태 업데이트가 transition 우선순위로 처리되는지는 서로 다른 이야기입니다.

 

즉 startTransition에 전달한 함수 안에서 첫 번째 await 이전에 호출한 상태 업데이트만 transition으로 표시되며, isPending 자체는 await 이후에도 계속 정확하게 추적됩니다.

 

await 이후 시점에서 호출하는 상태 업데이트를 transition으로 표시하려면 그 호출을 다시 startTransition으로 한 번 더 감싸는 것이 좋습니다.

 

다음으로는 이 비동기 작업이 실패했을 때 에러를 어떻게 처리하는지 살펴보겠습니다.

 

 

 

 

useTransition 내부에서 발생한 에러 처리하기

 

startTransition에 전달한 함수 내부에서 에러가 throw되고 별도로 catch하지 않으면, React는 이 에러를 가로채 가장 가까운 Error Boundary로 전달합니다.

 

콘솔에만 에러가 남거나 조용히 무시되는 것이 아니라, 일반 렌더링 에러와 동일한 경로로 Error Boundary까지 전파된다는 점이 핵심입니다.

 

개인적으로 가장 크게 활용될 부분은 위젯마다 에러 처리 코드를 반복하지 않고, Error Boundary 하나로 여러 비동기 작업의 실패를 한 번에 감쌀 수 있다는 점입니다.

 

다만 React는 함수형 Error Boundary API를 기본 제공하지 않기 때문에, 함수형 컴포넌트로 사용할 수 있는 react-error-boundary 라이브러리를 사용합니다.

 

위젯 단위로 에러를 보여줄지, 화면 전체를 Error Boundary에 맡길지는 상황에 따라 선택할 수 있습니다.

 

[ try/catch로 위젯 단위 에러 표시하기 ]

 

function ItemLoaderWithLocalError() {
  const [item, setItem] = useState<{ id: number; name: string } | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [isPending, startTransition] = useTransition()

  function handleLoad(id: number) {
    startTransition(async () => {
      setError(null)

      try {
        const next = await fetchRiskyItem(id)
        setItem(next)
      } catch {
        setError('아이템을 불러오지 못했습니다.')
      }
    })
  }
  // ...
}

 

 

 

[ 그대로 throw하여 Error Boundary에 위임하기 ]

 

function ItemLoader() {
  const [item, setItem] = useState<{ id: number; name: string } | null>(null)
  const [isPending, startTransition] = useTransition()

  function handleLoad(id: number) {
    startTransition(async () => {
      const next = await fetchRiskyItem(id)
      setItem(next)
    })
  }
  // ...
}

 

try/catch로 감싸면 그 위젯 안에서만 에러 메시지를 보여줄 수 있고, 그대로 두면 가장 가까운 Error Boundary의 fallback이 해당 서브트리 전체를 대체합니다.

 

그러므로 세밀한 위젯 단위 에러 표시가 필요하면 try/catch를, 화면 전체나 큰 영역 단위의 대체 UI로 충분하면 Error Boundary 위임을 사용하는 것이 좋습니다.

 

 

 

 

실제로 정상 요청과 실패하는 요청을 함께 확인할 수 있는 예제를 만들어 보겠습니다.

 

// pages/transition-error/fetchRiskyItem.ts
export async function fetchRiskyItem(id: number): Promise<{ id: number; name: string }> {
  const res = await fetch(`https://api.example.com/items/${id}`)

  if (!res.ok) {
    throw new Error('아이템을 불러오지 못했습니다.')
  }

  return res.json()
}

 

// pages/transition-error/ItemLoader.tsx
import { useState, useTransition } from 'react'
import { fetchRiskyItem } from './fetchRiskyItem'

export default function ItemLoader() {
  const [item, setItem] = useState<{ id: number; name: string } | null>(null)
  const [isPending, startTransition] = useTransition()

  function handleLoad(id: number) {
    startTransition(async () => {
      const next = await fetchRiskyItem(id)
      setItem(next)
    })
  }

  return (
    <div className="flex flex-col gap-4">
      <div className="flex gap-2">
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => handleLoad(1)}
        >
          정상 아이템 불러오기 (id: 1)
        </button>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => handleLoad(999)}
        >
          실패하는 아이템 불러오기 (id: 999)
        </button>
      </div>
      {isPending && <p className="text-sm text-gray-500">불러오는 중입니다...</p>}
      {item && <p className="text-sm">{item.name}</p>}
    </div>
  )
}

 

 

 

Error Boundary를 구성하기 위해서는 react-error-boundary 설치가 필요합니다.

 

// terminal
$ npm install react-error-boundary

 

// pages/transition-error/index.tsx
import { ErrorBoundary } from 'react-error-boundary'
import ItemLoader from './ItemLoader'

export default function TransitionErrorPage() {
  return (
    <div className="flex flex-col gap-4 p-6">
      <ErrorBoundary fallback={<p className="text-sm text-red-600">아이템을 불러오는 중 문제가 발생했습니다.</p>}>
        <ItemLoader />
      </ErrorBoundary>
    </div>
  )
}

 

실패하는 아이템 불러오기 버튼을 누르면 fetchRiskyItem이 throw한 에러가 ItemLoader를 감싸고 있는 ErrorBoundary까지 전파되어, fallback으로 지정한 문구가 화면에 표시됩니다.

 

다음으로는 같은 화면에서 transition을 연속으로 여러 번 실행했을 때 어떤 일이 일어나는지 살펴보겠습니다.

 

 

 

 

useTransition에서 여러 transition이 동시에 실행될 때의 동작

 

같은 useTransition 인스턴스에서 이전 transition이 아직 진행 중인데 startTransition을 다시 호출하면 어떻게 될까요?

 

React는 여러 개의 진행 중인 transition을 하나로 batch하여, 각 action 함수는 취소되지 않고 끝까지 독립적으로 실행되지만 isPending 하나가 그 그룹 전체를 대표하는 값이 됩니다.

 

즉 뒤에 호출한 transition만 반영되고 앞선 transition이 무시되는 것이 아니라, 둘 다 각자 끝까지 실행되며 isPending은 그 그룹에 속한 모든 action이 settle되어야 false로 돌아옵니다.

 

 

 

실제로 버튼 두 개를 연속으로 눌러 콘솔 로그로 확인해 보겠습니다.

 

// pages/concurrent-clicks/likeAction.ts
export async function likeAction(id: number): Promise<void> {
  console.log(`[likeAction] ${id} 시작`)
  await new Promise((resolve) => setTimeout(resolve, 1000))
  console.log(`[likeAction] ${id} 완료`)
}

 

// pages/concurrent-clicks/index.tsx
import { useTransition } from 'react'
import { likeAction } from './likeAction'

export default function ConcurrentClicksPage() {
  const [isPending, startTransition] = useTransition()

  function handleClick(id: number) {
    startTransition(async () => {
      await likeAction(id)
    })
  }

  return (
    <div className="flex flex-col gap-4 p-6">
      <div className="flex gap-2">
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => handleClick(1)}
        >
          아이템 1 좋아요
        </button>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          onClick={() => handleClick(2)}
        >
          아이템 2 좋아요
        </button>
      </div>
      <p className="text-sm text-gray-500">{isPending ? '처리 중입니다...' : '대기 중입니다.'}</p>
    </div>
  )
}

 

 

 

 

"아이템 1 좋아요"를 누르고 바로 이어서 "아이템 2 좋아요"를 누르면, 콘솔에 "1 시작", "2 시작", "1 완료", "2 완료" 로그가 각각 독립적으로 남으면서도 처리 중입니다 문구는 둘 다 끝날 때까지 계속 표시됩니다.

 

이 batch 동작은 향후 릴리스에서 제거될 수 있는 현재 제한사항(limitation)으로 React 공식 문서 Caveats 섹션에 명시되어 있습니다.

 

그러므로 지금 시점에는 여러 transition이 겹칠 가능성이 있는 화면이라면, 각 action이 서로 다른 순서로 완료되어도 최종 상태가 꼬이지 않도록 action 함수 내부에서 갱신하는 값의 순서에 유의하는 것이 좋습니다.

 

startTransition을 useOptimistic과 함께 사용해 낙관적 업데이트까지 처리하는 방법은 이전 포스팅에서 다룬 적이 있습니다.

 

관련 자료가 필요하신 분들은 작성된 글을 확인해 볼 수 있습니다.

 

React 19 useOptimistic, Server Action 없이 낙관적 업데이트를 처리하는 방법

 

React 19 useOptimistic, Server Action 없이 낙관적 업데이트를 처리하는 방법

안녕하세요. J4J입니다. 이번 포스팅은 React 19에 새롭게 추가된 useOptimistic 훅을 이용하여 낙관적 업데이트를 처리하는 방법에 대해 알아보는 시간을 가져보려고 합니다. useOptimistic을 사

jforj.tistory.com

 

 

 

 

 

 

이상으로 React 19의 useTransition과 Actions에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글