- Published on
React 19 useActionState로 폼 지연·중복 제출 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 요청이 느려지는 순간, 사용자 경험은 급격히 무너집니다. 특히 폼(form)에서는 더 치명적입니다. 버튼을 눌렀는데 반응이 없는 것처럼 보이면 사용자는 다시 누르고, 그 결과 중복 제출이 발생합니다. 결제·회원가입·예약 같은 흐름에서 중복 제출은 곧바로 데이터 정합성 문제(중복 레코드, 중복 결제, 중복 이메일 발송)로 이어집니다.
React 19에서는 이런 문제를 해결하기 위한 도구가 더 선명해졌습니다. 그중 핵심이 useActionState입니다. 이 훅은 “폼 제출 → 서버 액션 실행 → pending/결과 상태 반영”을 한 덩어리로 묶어주기 때문에, UI에서 지연(latency)과 중복 클릭을 다루는 방식이 훨씬 단순해집니다.
이 글에서는 useActionState를 이용해 다음을 구현합니다.
- 네트워크 지연 시에도 버튼을 안전하게 비활성화하여 중복 제출 방지
- 서버 검증 에러를 UI에 안정적으로 표시
- 제출 상태(pending)를 기준으로 로딩 UI/폼 잠금 처리
- (선택) idempotency(멱등성)까지 고려한 실전 패턴
왜 기존 방식은 중복 제출에 취약한가
전통적인 React 폼 구현은 대개 이런 형태였습니다.
onSubmit에서setLoading(true)await fetch(...)- 성공/실패 처리 후
setLoading(false)
문제는 다음과 같습니다.
- 상태 전파 타이밍: 클릭 직후 렌더링이 바로 반영되지 않으면 사용자는 1~2번 더 누릅니다.
- 경쟁 상태(race): 빠르게 연속 클릭하면 fetch가 여러 번 날아갈 수 있습니다.
- 에러/결과 상태의 산재: 로딩, 에러, 성공 데이터를 각각 다른 state로 관리하다 보면 케이스가 늘어나면서 버그가 증가합니다.
React 19의 useActionState는 “액션 실행과 상태 업데이트”를 React의 액션 모델에 맞게 통합해 이런 문제를 구조적으로 줄입니다.
React 19 useActionState 개념 정리
useActionState는 “액션 함수(action) + 상태(state) + pending”을 묶어서 제공합니다.
- 액션 함수는 보통
FormData를 받아 서버와 통신하거나(혹은 서버 액션을 호출하거나) 결과를 반환합니다. - 훅은 세 가지를 반환합니다.
state: 액션 결과를 담는 상태formAction:<form action={...}>에 연결할 함수isPending: 현재 액션이 진행 중인지 여부
핵심은 폼 제출 이벤트를 직접 처리(onSubmit)하기보다, action을 form에 연결하고 React가 액션 흐름을 관리하게 한다는 점입니다.
기본 예제: useActionState로 중복 제출 방지
아래 예제는 “이메일 구독 폼”을 가정합니다. 네트워크가 느려도 버튼이 즉시 pending 상태로 전환되며, pending 동안 버튼/입력을 잠가 중복 제출을 막습니다.
import * as React from "react";
type SubscribeState =
| { status: "idle" }
| { status: "success"; message: string }
| { status: "error"; message: string; fieldErrors?: { email?: string } };
async function subscribeAction(
prevState: SubscribeState,
formData: FormData
): Promise<SubscribeState> {
const email = String(formData.get("email") ?? "").trim();
// 1) 클라이언트/서버 공통으로 쓸 수 있는 최소 검증
if (!email) {
return {
status: "error",
message: "이메일을 입력해주세요.",
fieldErrors: { email: "필수 입력" },
};
}
// 2) 서버 요청(예: API)
try {
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return {
status: "error",
message: data?.message ?? "구독 처리에 실패했습니다.",
};
}
return { status: "success", message: "구독이 완료되었습니다." };
} catch {
return { status: "error", message: "네트워크 오류가 발생했습니다." };
}
}
export function SubscribeForm() {
const [state, formAction, isPending] = React.useActionState<
SubscribeState,
FormData
>(subscribeAction, { status: "idle" });
return (
<form action={formAction}>
<label>
이메일
<input
name="email"
type="email"
disabled={isPending}
aria-invalid={state.status === "error" && !!state.fieldErrors?.email}
/>
</label>
{state.status === "error" && state.fieldErrors?.email && (
<p role="alert">{state.fieldErrors.email}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "처리 중..." : "구독"}
</button>
{state.status === "success" && <p>{state.message}</p>}
{state.status === "error" && !state.fieldErrors?.email && (
<p role="alert">{state.message}</p>
)}
</form>
);
}
이 코드가 중복 제출을 막는 이유
isPending이 true인 동안button disabled가 적용되어 연속 클릭이 물리적으로 차단됩니다.input disabled로 폼 전체를 잠그면 “제출 중 값 변경 → 다른 값으로 또 제출” 같은 혼선을 줄일 수 있습니다.- 결과 상태가
state로 한 곳에 모여, “로딩/성공/에러” 흐름이 단순해집니다.
지연이 심한 환경에서 UX를 더 안전하게 만드는 팁
중복 제출 방지는 버튼 비활성화로 끝나지 않습니다. 지연이 길어지면 사용자는 “진짜로 동작 중인지” 의심합니다. 다음을 함께 적용하면 체감이 크게 좋아집니다.
1) pending 시 즉시 피드백(스피너/문구) 제공
위 예제처럼 버튼 라벨을 바꾸는 것만으로도 효과가 큽니다. 더 나아가 입력 영역 상단에 상태 배너를 두면 좋습니다.
{isPending && <p aria-live="polite">서버에 요청 중입니다. 잠시만 기다려주세요.</p>}
2) INP(Interaction to Next Paint) 관점에서 “즉시 렌더” 확보
중복 클릭은 네트워크만의 문제가 아니라, 클릭 후 화면 반응이 늦을 때도 발생합니다. 메인 스레드에 Long Task가 있으면 pending UI가 늦게 그려집니다. 폼 제출 직후 UI가 바로 바뀌도록 렌더링을 가로막는 작업을 줄이는 게 중요합니다.
관련해서는 Chrome INP 개선 - Long Task 50ms 잡는 법 글의 체크리스트(긴 JS 작업 분해, 불필요한 re-render 감소, 입력 이벤트 핸들러 최소화)가 폼 UX에도 그대로 적용됩니다.
서버 검증 에러를 “필드 단위”로 안전하게 매핑하기
실전에서는 서버가 필드별 에러를 내려주는 경우가 많습니다. 이때 useActionState의 state에 fieldErrors를 표준 형태로 넣어두면 폼 컴포넌트가 단순해집니다.
예: 서버가 다음처럼 응답한다고 가정합니다.
{
"message": "Validation failed",
"errors": {
"email": "이미 사용 중인 이메일입니다"
}
}
액션에서 이를 파싱해 fieldErrors로 매핑합니다.
if (!res.ok) {
const data = await res.json().catch(() => null);
return {
status: "error",
message: data?.message ?? "요청이 실패했습니다.",
fieldErrors: data?.errors,
};
}
이 패턴은 타입을 엄격히 유지하는 게 중요합니다. 특히 TypeScript 설정이 엄격할수록(예: noImplicitAny) 서버 응답 형태가 조금만 흔들려도 오류가 나기 쉽습니다. 타입 추론이 꼬일 때는 TypeScript 5.5 noImplicitAny 폭탄 - inferred type 디버깅에서 소개하는 “추론 경로를 끊고 명시 타입을 둔다” 전략이 폼 상태 모델링에도 도움이 됩니다.
(중요) 프론트에서 막아도, 서버는 멱등성을 보장해야 한다
isPending으로 버튼을 막아도 100% 안전하진 않습니다.
- 사용자가 새로고침 후 재전송
- 네트워크 재시도(프록시/클라이언트)
- 모바일 환경에서 탭이 멈췄다 재개되며 요청이 중복
따라서 “중복 제출 방지”의 최종 책임은 서버에 있습니다. 가장 흔한 해결책은 Idempotency Key(멱등 키) 입니다.
Idempotency Key 적용 예시
클라이언트에서 요청마다 고유 키를 생성해 헤더로 보냅니다.
function createIdempotencyKey() {
return crypto.randomUUID();
}
// 액션 내부
const key = createIdempotencyKey();
const res = await fetch("/api/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ email }),
});
서버는 (user, idempotencyKey) 조합으로 “이미 처리된 요청이면 이전 결과를 반환”하도록 저장/캐시합니다.
- 결제/주문 생성: DB에 unique index 또는 idempotency 테이블
- 이메일 발송: 발송 로그 테이블로 중복 방지
useActionState는 UI 레벨에서 중복을 크게 줄여주고, 서버 멱등성은 데이터 레벨에서 중복을 근본적으로 차단합니다. 두 축이 함께 가야 안전합니다.
폼이 여러 개인 페이지에서의 패턴: 액션을 폼 단위로 분리
한 페이지에 폼이 2개 이상이면, 흔히 “전역 로딩 상태”를 두었다가 한 폼 제출이 다른 폼까지 잠가버리는 문제가 생깁니다. useActionState는 훅을 폼 컴포넌트 단위로 두면 자연스럽게 분리됩니다.
- 각 폼이 자기
isPending을 가짐 - 각 폼이 자기
state를 가짐 - 결과적으로 상호 간섭이 줄어듦
컴포넌트를 작게 쪼개는 것만으로도 “의도치 않은 중복 제출/비활성화” 버그가 크게 감소합니다.
디버깅 체크리스트: 여전히 중복 제출이 난다면
다음 항목을 점검하세요.
- 버튼이 진짜로 disabled 되는가?
- CSS로만 비활성화(클릭 가능) 처리한 경우가 많습니다.
disabled속성 자체가 필요합니다.
- CSS로만 비활성화(클릭 가능) 처리한 경우가 많습니다.
- 폼 제출이 두 경로로 발생하지 않는가?
action={formAction}과onSubmit에서 별도fetch를 함께 쓰면 중복 호출됩니다.
- 컴포넌트가 언마운트/리마운트되며 state가 초기화되지 않는가?
- 라우팅/조건부 렌더링 때문에
isPending이 끊기면 사용자가 다시 제출할 수 있습니다.
- 라우팅/조건부 렌더링 때문에
- 서버가 멱등하지 않은가?
- UI에서 막았는데도 중복 데이터가 생기면, 서버에서 “같은 요청을 두 번 처리”하고 있을 가능성이 큽니다.
마무리
React 19의 useActionState는 폼 제출을 “이벤트 핸들링”에서 “액션 기반 상태 흐름”으로 바꿔, 지연 상황에서 특히 취약한 중복 제출 문제를 간결하게 해결합니다.
정리하면 다음 3가지를 함께 적용하는 것이 가장 안전합니다.
useActionState의isPending으로 버튼/입력 비활성화state에 성공/에러/필드 에러를 표준 형태로 모아 UI를 단순화- 서버는 Idempotency Key 등으로 멱등성을 보장
이 조합이면 네트워크가 느린 환경에서도 “사용자가 불안해서 연타하는 문제”를 UX와 데이터 정합성 양쪽에서 동시에 잡을 수 있습니다.