TechFeedTechFeed
Frontend

useFormStatus, useActionState, 이중 제출, 서버 액션 | 문의 버튼이 두 번 눌리면?

문의 폼 버튼이 두 번 눌리면 자식에서 폼 상태 훅의 대기로 잠근다. 액션 상태 훅은 서버가 돌려준 에러를 남기고, 같은 컴포넌트의 폼은 대기가 안 뜬다. React, Next.js, 서버 액션, 프론트엔드, 개발자, API, 폼데이터 기준으로 제출 중 칸만 보고 서버 액션 보안 글과 자리를 섞지 않는다. 2026년 8월 공식 문서.

by

문의가 같은 내용으로 두 통 쌓인 아침이 있습니다. 버튼 글자가 안 바뀌어서 한 번 더 누른 겁니다. 리액트 19의 폼 상태 훅은 부모 폼이 제출 중이면 대기가 참입니다. 같은 컴포넌트의 폼은 보지 않고, 자식 버튼에서만 읽습니다. 액션 상태 훅은 서버가 돌려준 에러 문구를 화면에 남깁니다. 훅을 붙인 액션의 첫 인자는 이전 상태이고, 폼데이터는 둘째입니다. 저는 12개 사이트 파트너십 폼에서 버튼을 자식으로 뺐습니다. 봇 칸은 턴스타일 글에, 쿠키는 세임사이트 글에, 캐시 무효화는 서버 액션 글에 있습니다. 여기는 제출 중 버튼과 에러 칸만 봅니다.


숫자는 2026년 8월 리액트 폼 상태 훅 문서와 넥스트 폼 가이드 기준입니다.


같은 문의가 두 통 쌓인 아침

한국 휴대폰에서 문의 보내기를 두 번 누르면, 서버는 두 건을 받습니다. 대기 표시가 없으면 손가락이 한 번 더 갑니다.


리액트 공식 문서는 폼 상태 훅을 리액트 돔에서 가져온다고 적습니다. 반환은 대기, 데이터, 메서드, 액션입니다. 대기가 참이면 부모 폼이 제출 중입니다. 같은 컴포넌트에 폼을 두고 훅을 부르면 대기는 끝까지 거짓입니다. 넥스트 폼 가이드는 검사 실패 문구를 남길 때 액션 상태 훅을 쓰라고 적습니다. 그때 서버 함수 첫 인자는 이전 상태가 됩니다.


파트너십 페이지를 열면 한국 사용자가 먼저 누릅니다. 카카오 로그인 쿠키 칸은 세임사이트 글입니다. 봇만 막을 때는 턴스타일입니다. 오늘은 사람 손이 두 번 가는 자리만 봅니다.


빠른 갈래 | 제출 중 버튼 글자와 비활성은 자식에서 폼 상태 훅을 읽습니다. 서버가 돌려준 문구는 액션 상태 훅입니다. 캐시 태그 무효화와 소유권 검사는 서버 액션 글입니다.


대기 표시와 에러 문구를 누가 맡나

폼 상태 훅은 지금 제출 중인지만 봅니다. 액션 상태 훅은 끝난 뒤에 남은 값을 봅니다.


항목폼 상태 훅액션 상태 훅
가져오는 곳리액트 돔리액트
읽는 위치부모 폼의 자식폼을 만든 클라이언트 컴포넌트
돌려주는 값대기, 데이터, 메서드, 액션상태, 전달 함수, 대기
잘 맞는 자리보내기 버튼, 입력 잠금검사 실패 문구, 이전 입력 유지
액션 인자그대로 폼데이터첫 인자는 이전 상태, 둘째가 폼데이터
연속 클릭대기 동안 버튼을 잠근다호출을 줄 세워 이전 결과를 다음 호출에 넘긴다

넥스트 폼 가이드는 검사 오류를 보여줄 때 폼을 클라이언트 컴포넌트로 바꾸고 액션 상태 훅을 붙이라고 적습니다. 리액트 문서는 폼의 액션 속성에 전달 함수를 주면 전환으로 감싼다고 적습니다. 클릭 핸들러에서 직접 부르면 전환 시작으로 감싸야 대기가 바뀝니다.


예전 이름은 폼 상태였습니다. 리액트 19 공개 글이 액션 상태 훅으로 바꿨다고 적습니다. 새 프로젝트는 새 이름만 씁니다.


폼 상태 훅과 액션 상태 훅 비교표. 대기 표시와 에러 문구 자리
버튼 잠금은 폼 상태 훅, 서버가 돌려준 문구는 액션 상태 훅이다

버튼을 폼 자식으로 빼는 순서

보내기 버튼은 폼 밖에 두지 않습니다. 훅은 부모 폼만 보고, 같은 파일의 폼은 무시합니다.


리액트 함정 칸이 그 실수를 예시로 둡니다. 폼 함수 안에서 훅을 부르면 대기는 끝까지 거짓입니다. 제출 컴포넌트를 만들고, 그 안에서 대기를 읽어 버튼을 잠급니다. 넥스트 앱 라우터면 폼을 만든 파일 위에 유즈 클라이언트를 둡니다. 서버 액션 자체는 유즈 서버 파일에 둡니다.


폼 상태 훅. 버튼을 자식으로 뺀 문의 폼
'use client' import { useFormStatus } from 'react-dom' import { sendInquiry } from './actions' function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? '보내는 중' : '문의 보내기'} </button> ) } export function InquiryForm() { return ( <form action={sendInquiry}> <label htmlFor="message">내용</label> <textarea id="message" name="message" required rows={5} /> <SubmitButton /> </form> ) }
액션 상태 훅. 검사 실패 문구를 화면에 남김
'use client' import { useActionState } from 'react' import { sendInquiry } from './actions' const initial = { ok: false, message: '' } export function InquiryForm() { const [state, formAction, pending] = useActionState(sendInquiry, initial) return ( <form action={formAction}> <label htmlFor="message">내용</label> <textarea id="message" name="message" required rows={5} disabled={pending} /> <button type="submit" disabled={pending}> {pending ? '보내는 중' : '문의 보내기'} </button> {state.message ? <p role="alert">{state.message}</p> : null} </form> ) }

액션 파일을 붙일 때는 첫 인자를 이전 상태로 바꿉니다. 이름만 꺼내면 빈 값이 됩니다. 넥스트 에러 처리 문서도 같은 순서를 적습니다. 예상한 실패는 던지지 않고 상태에 담아 돌립니다. 던지면 가까운 에러 경계로 가고, 뒤에 줄 선 호출은 빠집니다.


서버 액션. 첫 인자는 이전 상태, 둘째가 폼데이터
'use server' export async function sendInquiry(prev, formData) { const message = String(formData.get('message') || '').trim() if (message.length < 10) { return { ok: false, message: '내용을 10자 이상 적어 주세요.' } } await saveInquiry({ message }) return { ok: true, message: '접수했습니다.' } }

버튼을 잠가도 서버는 두 번 받을 수 있다

대기 동안 버튼을 꺼도 네트워크가 두 번 열리면 서버는 두 건을 받습니다. 화면 잠금과 서버에 한 줄만 남기는 칸은 자리가 다릅니다.


리액트는 액션 상태 훅의 연속 호출을 줄 세웁니다. 이전 반환이 다음 첫 인자입니다. 장바구니 수량을 더하는 예는 그렇게 동작합니다. 문의 저장은 더하기가 아닙니다. 같은 본문이 두 줄이면 중복입니다. 웹훅 재시도와 같은 멱등 키를 저장 칸에 둡니다. 그 칸은 웹훅 멱등 글입니다.


자바스크립트가 늦게 붙는 첫 페인트에서는 네이티브 제출이 먼저 갈 수 있습니다. 리액트는 퍼머링크 인자를 페이지 주소와 맞추라고 적습니다. 넥스트가 라우트를 맞춰 주는 경우가 많아서, 동적 주소만 따로 확인합니다. 테스트는 플레이라이트로 보내기를 두 번 눌러 한 건만 남는지 봅니다. 그 순서는 플레이라이트 글입니다.


이중 제출을 막는 두 칸. 화면 버튼 잠금과 서버 멱등 키
버튼 비활성만으로는 부족하다. 같은 본문이 두 줄이면 저장 칸에서 한 번만 남긴다

잠금 한 줄 | 화면은 대기가 참일 때 버튼을 끕니다. 서버는 같은 세션과 같은 본문의 두 번째 저장을 거절합니다. 한쪽만 있으면 두 통이 남습니다.


액션 첫 인자가 폼데이터가 아닌 날

액션 상태 훅을 붙인 뒤에 이름이 비면, 첫 인자를 폼데이터로 읽은 겁니다. 둘째로 옮기면 값이 돌아옵니다.


리액트 문제 해결 칸이 그 순서를 적습니다. 훅 없이 액션만 쓰면 첫 인자가 폼데이터입니다. 훅을 씌우면 이전 상태가 앞에 끼어듭니다. 넥스트 폼 가이드도 같은 변경을 적습니다. 콘솔에 객체의 겟이 없다고 뜨면 이 칸부터 봅니다.


증상먼저 볼 곳
대기가 끝까지 거짓훅을 폼과 같은 컴포넌트에서 부름
이름 칸이 비어 저장됨액션 첫 인자를 폼데이터로 읽음
대기가 안 바뀜클릭에서 직접 부르면서 전환 시작을 안 감쌈
두 번째 클릭이 사라짐앞 액션이 던져서 줄이 비워짐
문구가 성공 뒤에도 남음성공 반환에 빈 메시지를 안 넣음

배포 직후 옛 클라이언트가 남으면 훅 이름이 어긋날 수 있습니다. 그 칸은 캐시 컨트롤입니다. 테일윈드 클래스만 갈아끼우는 자리는 테일윈드 v4입니다.


오늘 맞출 네 줄

문의 폼은 버튼 자식, 액션 인자 순서, 서버 한 번만, 검사 문구 네 줄이면 됩니다.


맞출 것
1보내기 버튼을 폼 자식으로 빼고, 대기면 비활성
2액션 상태 훅을 쓰면 첫 인자는 이전 상태
3같은 세션 같은 본문은 서버에서 한 줄만
4예상 실패는 던지지 않고 문구로 돌림

저는 파트너십 폼을 이렇게 나눴습니다. 한국 모바일에서 보내기가 느리면 글자만 바꿔도 두 번째 탭이 줄었습니다. 봇과 쿠키와 캐시는 자리를 섞지 않습니다.


문의 폼 오늘 맞출 네 줄. 자식 버튼, 인자 순서, 서버 한 번, 검사 문구
화면 잠금과 서버 한 줄을 같이 맞춘다. 훅만 넣고 저장을 두 번 받으면 메일이 두 통이다

참고 자료


훅 이름과 인자 순서는 쓰는 리액트 마이너가 우선입니다. 위 표는 2026년 8월 공개 문서 기준입니다.


자주 묻는 질문

폼 상태 훅만 쓰면 되나?

버튼 글자와 비활성만 필요하면 그걸로 됩니다. 서버가 돌려준 검사 문구를 남기려면 액션 상태 훅이 필요합니다. 둘을 한 파일에 같이 쓸 수는 있습니다. 폼 상태 훅만 같은 컴포넌트 폼에서는 거짓입니다.


리액트 훅 폼을 빼야 하나?

이미 필드 단위 검사가 길면 유지합니다. 문의 칸 하나와 보내기 버튼이면 내장 훅이 짧습니다. 라이브러리 온서브밋과 액션 속성을 같이 쓰면 제출이 두 갈래가 됩니다. 한쪽만 남깁니다.


버튼을 disabled만 하면 이중 제출이 끝나나?

화면에서는 줄어듭니다. 서버는 같은 본문을 한 줄만 남기는 키가 필요합니다. 새로고침 직후 네이티브 제출이 한 번 더 갈 수 있습니다.


넥스트 서버 액션 글이랑 뭐가 다른가?

그 글은 캐시 무효화와 소유권 검사입니다. 여기는 제출 중 버튼과 에러 문구, 인자 순서만 봅니다. 자리를 섞지 않습니다.


대기 중에 입력도 잠가야 하나?

내용이 바뀌면 두 번째 값이 저장됩니다. 텍스트 영역도 대기면 잠급니다. 보내기만 끄고 입력을 열어 두면 손이 한 번 더 갑니다.


한국 모바일에서 특히 두 번 누르나?

네트워크가 느리면 버튼이 그대로여서 탭이 한 번 더 갑니다. 글자를 보내는 중으로 바꾸는 것만으로도 두 번째 탭이 줄었습니다. 숫자 벤치는 기기마다 달라서 공식 문서 숫자로 단정하지 않습니다.


useFormStatususeActionState이중 제출서버 액션ReactNext.js프론트엔드개발자APIpending폼데이터

관련 도구

함께 보면 좋은 문제 해결

EXPLORE / Frontend

이어서 읽어보기

전체 토픽 둘러보기