Cheondi
개발 · 웹

React 19 Actions와 거래 화면 상태

React 19 Actions와 낙관적 상태를 거래 요청 화면에 대입하며 pending, 중복 제출, 서버 확정 상태의 경계를 정리했습니다.

  • #react
  • #react19
  • #actions
  • #state
  • #transaction

거래 요청 화면에서는 버튼 하나의 상태가 생각보다 복잡했다. 사용자가 제출한 순간, 서버가 요청을 받은 순간, 외부 처리 결과가 확정된 순간이 모두 다르다. 버튼을 두 번 눌렀을 때 중복 요청도 막아야 하고, 오류가 나면 입력값을 유지한 채 다시 시도할 수 있어야 한다.

기존에는 loading, error, result를 각각 useState로 두고 직접 순서를 맞췄다. React 19의 Actions와 useActionState, useOptimistic을 보면서 폼 제출의 비동기 상태를 더 가까운 곳에 모을 수 있겠다고 생각했다. 다만 편한 API가 거래 상태의 진실까지 결정해 주는 것은 아니었다.

제출 상태를 Action에 모으기

간단한 요청 폼을 다음처럼 만들 수 있다.

import { useActionState } from 'react';

const initialState = { status: 'idle', message: '' };

async function submitOrder(previous, formData) {
  const response = await fetch('https://api.example.com/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      accountId: formData.get('accountId'),
      amount: formData.get('amount')
    })
  });

  if (!response.ok) {
    return { status: 'failed', message: '요청을 확인해 주세요.' };
  }

  const order = await response.json();
  return { status: 'accepted', orderId: order.id, message: '' };
}

컴포넌트는 action과 pending을 폼에 바로 연결한다.

function OrderForm() {
  const [state, formAction, isPending] = useActionState(
    submitOrder,
    initialState
  );

  return (
    <form action={formAction}>
      <input name="accountId" defaultValue="example-account" />
      <input name="amount" inputMode="decimal" />
      <button disabled={isPending}>
        {isPending ? '요청 중' : '요청하기'}
      </button>
      <p aria-live="polite">{state.message}</p>
    </form>
  );
}

isPending으로 사용자 인터페이스의 중복 클릭은 줄일 수 있다. 하지만 네트워크 재전송, 여러 탭, 앱 재시작 뒤 재시도까지 막지는 못한다. 서버에는 별도의 idempotency 기준이 필요하다.

POST /orders HTTP/1.1
Host: api.example.com
Idempotency-Key: example-request-0182
Content-Type: application/json

낙관적으로 보여도 되는 상태

useOptimistic은 서버 응답 전에 예상 결과를 화면에 보여 줄 수 있다. 댓글이나 좋아요처럼 실패 시 되돌리기 쉬운 기능에는 자연스럽다. 하지만 거래 승인이나 잔액은 실제 서버 처리가 끝나기 전에 확정처럼 보이면 안 된다.

const [optimisticRows, addOptimisticRow] = useOptimistic(
  rows,
  (current, pendingOrder) => [
    { ...pendingOrder, state: 'sending', temporary: true },
    ...current
  ]
);

여기서 낙관적으로 추가한 행은 완료가 아니라 전송 중이다. 서버 응답으로 실제 ID를 받고 나서 접수, 후속 조회나 이벤트에서 확인한 뒤 완료로 바꾼다.

화면 상태 근거 표시 가능한 표현
editing 사용자 입력 입력 중
sending Action 실행 요청 중
accepted 생성 API 성공 접수됨
processing 서버 상태 조회 처리 중
completed 서버의 최종 상태 완료
failed 명시적 실패 응답 실패·재시도 안내

HTTP 200도 업무 완료와 같지 않을 수 있다. API가 비동기 처리 접수만 반환한다면 UI 상태 이름도 success보다 accepted가 정확하다.

오류와 재시도에서 남겨야 할 것

Action이 실패해도 사용자가 입력한 값을 무조건 비우지 않는 편이 좋다. 형식 오류는 해당 필드 가까이에, 서버 오류는 폼 전체 상태로 보여 줄 수 있다. 요청이 서버에 도착했는지 알 수 없는 timeout은 바로 같은 요청을 새 키로 반복하지 않고 기존 키로 상태를 확인하는 흐름이 안전하다.

timeout 발생
  → 같은 idempotency key로 결과 조회
  → 접수 확인: 상태 화면으로 이동
  → 미접수 확인: 재시도 허용
  → 확인 불가: 중복 가능성을 안내하고 자동 반복 중지

React가 해결하는 것과 서버가 해결하는 것

  • React Action은 제출과 pending 상태를 컴포넌트 가까이에 둔다.
  • useOptimistic은 되돌릴 수 있는 임시 표현을 다룬다.
  • 서버는 권한, 금액 검증, 중복 방지와 최종 상태를 소유한다.
  • UI는 “요청 전송”과 “업무 완료”를 다른 문구로 표시한다.
  • 새로고침 뒤에도 상태가 필요하면 서버에서 다시 읽는다.

React 19의 API는 비동기 폼 코드를 정리하는 데 도움이 됐다. 그러나 상태를 덜 작성한다고 상태 자체가 줄어드는 것은 아니었다. 오히려 어느 상태가 클라이언트의 예상이고 어느 상태가 서버의 확정인지 이름으로 드러낼 때, 편한 문법을 안전하게 사용할 수 있었다.

참고: React 19 공식 발표