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