2026. 7. 16.
Next.js Server Action이 두 번 실행된다면 pending 동안 제출 버튼을 잠그세요
Next.js Server Action 폼에서 응답 전에 저장 버튼을 다시 누르게 된다면 useActionState 또는 form 안의 useFormStatus로 pending 상태를 읽고 버튼을 잠근 뒤 느린 제출로 검증한다.

Next.js App Router에서 저장 버튼을 눌렀는데 응답이 늦으면 사용자는 “안 눌렸나?” 하고 다시 누르기 쉽다. 이때 먼저 할 일은 클릭 핸들러를 더 만드는 것이 아니라 Server Action이 끝날 때까지 제출 버튼을 잠그고 처리 중 상태를 보여주는 것이다.
폼의 결과와 오류까지 한곳에서 관리한다면 useActionState가 돌려주는 pending을 쓴다. 제출 버튼만 재사용 컴포넌트로 뺀다면 해당 form 안의 자식에서 useFormStatus를 호출한다. 두 방식 모두 목표는 같다. 대기 중에는 두 번째 클릭을 받지 않고, 사용자가 지금 저장 중임을 알게 하는 것이다.
먼저 pending으로 버튼을 잠급니다
Next.js Forms 공식 가이드는 useActionState의 세 번째 반환값인 pending으로 로딩 상태를 표시하거나 제출 버튼을 비활성화하는 예제를 제공한다. 폼 결과와 검증 오류도 같은 컴포넌트에서 보여줘야 한다면 이 경로가 가장 곧다.
'use client'
import { useActionState } from 'react'
import { saveProfile } from './actions'
const initialState = { message: '' }
export function ProfileForm() {
const [state, formAction, pending] = useActionState(
saveProfile,
initialState,
)
return (
<form action={formAction}>
<label htmlFor="nickname">닉네임</label>
<input id="nickname" name="nickname" disabled={pending} />
<button type="submit" disabled={pending}>
{pending ? '저장 중…' : '저장'}
</button>
<p aria-live="polite">{state.message}</p>
</form>
)
}
여기서 확인할 결과는 세 가지다.
- 제출 직후 버튼 글자가
저장 중…으로 바뀐다. - 대기 중 버튼과 입력칸이 비활성화된다.
- 완료 뒤 버튼이 다시 열리고 결과 문구가 보인다.
단순히 버튼 색만 흐리게 하지 말고 실제 disabled={pending}를 넣는다. 그래야 같은 버튼을 빠르게 다시 누르는 입력을 브라우저 수준에서 막을 수 있다. 버튼이 아예 반응하지 않는 문제라면 pending을 붙이기 전에 React 클릭 신호를 Console부터 추적하는 순서로 이벤트 경계를 먼저 확인한다.
useActionState를 쓰면 FormData는 두 번째 인자입니다
useActionState를 연결하면 Server Action의 함수 모양도 달라진다. React useActionState 공식 문서는 Action이 이전 상태를 첫 번째 인자로 받고, 제출 데이터가 그다음 인자로 전달된다고 설명한다.
'use server'
type FormState = {
message: string
}
export async function saveProfile(
previousState: FormState,
formData: FormData,
): Promise<FormState> {
const nickname = String(formData.get('nickname') ?? '').trim()
if (!nickname) {
return { message: '닉네임을 입력하세요.' }
}
// 데이터 저장 로직
return { message: `${nickname}으로 저장했습니다.` }
}
기존 Action이 saveProfile(formData) 모양이었는데 훅만 붙이고 함수 인자를 그대로 두면, 첫 번째 인자를 FormData로 착각해 formData.get is not a function 같은 새 오류를 만들 수 있다. 훅을 붙일 때는 호출부와 Action 시그니처를 한 쌍으로 검토한다.
제출 버튼만 분리하면 useFormStatus를 씁니다
폼마다 같은 제출 버튼을 쓰고 싶다면 useFormStatus가 더 작게 맞는다. 중요한 조건은 훅을 호출하는 컴포넌트가 상태를 읽을 form의 안쪽 자식이어야 한다는 점이다.
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '저장 중…' : '저장'}
</button>
)
}
Server Component의 폼은 이 버튼을 안쪽에 렌더한다.
import { saveProfile } from './actions'
import { SubmitButton } from './SubmitButton'
export function ProfileForm() {
return (
<form action={saveProfile}>
<input name="nickname" />
<SubmitButton />
</form>
)
}
같은 컴포넌트에서 form을 만들면 pending이 잡히지 않습니다
React useFormStatus 공식 문서는 이 훅이 부모 form의 상태만 읽는다고 명시한다. 아래처럼 훅을 호출한 컴포넌트가 그 자리에서 form까지 반환하면 추적할 부모 폼이 없어서 pending은 계속 false일 수 있다.
function BrokenForm() {
const { pending } = useFormStatus()
return (
<form action={saveProfile}>
<button disabled={pending}>저장</button>
</form>
)
}
수정 기준은 단순하다. useFormStatus를 호출하는 버튼을 별도 자식 컴포넌트로 빼고, 추적하려는 form 안에 넣는다. API 이름이 맞는데도 상태가 바뀌지 않을 때는 코드 줄보다 컴포넌트 트리부터 본다.
느린 Action으로 두 번째 클릭을 재현합니다
로컬에서 Action이 너무 빨리 끝나면 pending 상태를 눈으로 확인하기 어렵다. 개발 중에만 잠시 지연을 넣어 버튼 잠금과 상태 전환을 확인할 수 있다.
if (process.env.NODE_ENV === 'development') {
await new Promise((resolve) => setTimeout(resolve, 1500))
}
이 코드는 제품 기능이 아니라 검증용 재현 장치다. 확인이 끝나면 제거한다. 다음 순서로 테스트한다.
- DevTools Network와 Console을 연다.
- 저장 버튼을 한 번 누른 직후 다시 빠르게 누른다.
- 첫 클릭 뒤 버튼이 즉시
disabled가 되는지 본다. - 처리 중 문구가 보이고 완료 뒤 결과 문구로 바뀌는지 본다.
- 지연 코드를 제거한 뒤 실제 저장 흐름을 다시 확인한다.
페이지 전체가 새로 로드되며 입력값이 사라진다면 이 문제와 다른 분기다. 그때는 React 폼 제출 뒤 새로고침을 type과 submit으로 구분하는 기준에서 브라우저 기본 제출과 Action 폼을 먼저 나눈다.
버튼 잠금과 서버 중복 방지는 범위가 다릅니다
disabled={pending}은 현재 화면에서 사용자가 같은 제출 버튼을 반복해서 누르는 일을 줄인다. 하지만 네트워크 재시도와 다른 탭의 요청까지 막는 서버 보장은 아니다. 결제·주문·쿠폰처럼 같은 작업이 두 번 처리되면 손실이 생기는 기능은 서버에서도 고유 요청 키, 데이터베이스 제약, 처리 상태 같은 별도 경계를 설계해야 한다.
이 글의 완료 상태는 다음과 같다.
- 한 번 누르면 즉시
저장 중…으로 바뀐다. - pending 동안 같은 버튼을 다시 누를 수 없다.
- 성공 또는 예상 가능한 오류가
aria-live영역에 보인다. useFormStatus를 썼다면 버튼이 추적할 form 안쪽에 있다.- 검증용 지연을 제거한 뒤에도 실제 저장이 정상 동작한다.
공식 확인 링크
이 글은 AI 코딩과 개발 학습의 일반 정보 제공 목적입니다. 도구, 모델, 커리큘럼, 요금은 버전과 시점에 따라 달라질 수 있으므로 실습이나 도입 전 공식 문서와 최신 릴리스 노트를 확인하세요.
다음으로 읽을 기사
같은 흐름으로 이어 읽기 좋은 기사만 추려 보여줍니다.
첫 번째 댓글을 남겨보세요
여러분의 생각이 다른 독자에게 도움이 됩니다.
댓글 0
이 글을 읽은 독자들의 생각을 나눠보세요.