거래 요청 화면에서는 버튼 하나의 상태가 생각보다 복잡했다. 사용자가 제출한 순간, 서버가 요청을 받은 순간, 외부 처리 결과가 확정된 순간이 모두 다르다. 버튼을 두 번 눌렀을 때 중복 요청도 막아야 하고, 오류가 나면 입력값을 유지한 채 다시 시도할 수 있어야 한다.
기존에는 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 공식 발표
후속 확인 항목
나중에 같은 증상을 다시 만나지 않으려고 문서에 적힌 기준으로 구현을 한 번 더 정리했다.
React 19의 Actions는 비동기 변경 과정에서 pending, error, optimistic update를 함께 다룰 수 있게 한다. 그래도 거래 요청의 중복 방지나 서버 멱등성까지 React가 해결해 주는 것은 아니다. UI 전이와 서버 효과를 다른 층으로 두어야 한다.
화면에서는 진행 중인 Action과 마지막으로 확정된 서버 결과를 분리하고, 응답이 왔을 때 현재 선택한 계좌와 요청 식별자가 같은지 확인하는 편이 안전하다.
다음에 React 19 Actions와 거래 화면 상태 같은 문제를 보면 아래 순서부터 확인하려고 한다.
- Action별 pending·error 상태
- 낙관적 값과 서버 확정 값 분리
- 계좌 변경 뒤 늦은 응답 폐기
- 중복 제출을 막는 서버 키
낙관적 화면이 실패했을 때 되돌릴 기준과 재전송 키가 없으면 빠른 UI가 오히려 실제 상태를 가릴 수 있다.
공식 참고 자료
- RFC 9110 HTTP Semantics — HTTP 메서드·상태 코드·멱등성 의미