HTML 폼 제출5분 분량
페이지 새로고침 없이 HTML 폼 제출하기
fetch와 FormData로 폼을 보내 방문자를 페이지에 머물게 하세요. 필드 오류, 로딩 상태, 재시도, 스크립트 실패 시 대체 경로까지 다룹니다.
폼의 submit 이벤트를 듣고, preventDefault()를 호출하고, Accept: application/json 헤더와 함께 fetch로 new FormData(form)을 보내세요. 그러면 Ikiform이 리디렉션 대신 JSON으로 응답합니다. 성공하면 201, 아니면 각 입력 옆에 보여 줄 필드 오류입니다. 스크립트가 실행되지 않아도 일반 방식으로 제출되도록 폼에 실제 action과 method를 유지하세요.
여기서 예시는 문서 페이지 하단의 Was this page helpful?(이 페이지가 도움이 되었나요?) 상자입니다. 페이지에 머무는 전형적인 경우입니다. 독자는 작업 중이고, 클릭 한 번을 기록하려고 감사 페이지로 보내는 것은 터무니없습니다. 같은 패턴은 어떤 폼에도 통합니다.
제출을 넘겨받으면 바뀌는 것
일반 폼 POST는 많은 것을 공짜로 줍니다. 브라우저가 로딩 표시를 보여 주고, 더블 클릭이 큰 문제가 되지 않게 하며, 뒤로 가기 버튼을 처리하고, 돌아오는 페이지를 보여 줍니다. preventDefault()를 호출하는 순간 그 모든 것이 내 일이 됩니다. fetch 호출 자체는 세 줄이고, 이 페이지의 나머지는 브라우저가 하던 일입니다.
- 브라우저가 먼저 제약 조건 확인을 실행합니다. submit 이벤트는 폼이 유효할 때만 발생합니다.
- 리스너가 preventDefault를 호출하므로 탭이 이동하지 않습니다.
- fetch로 폼 데이터를 보내고 JSON을 요청합니다.
- 201이면 폼을 상태 메시지로 바꾸고 포커스를 그곳으로 옮깁니다.
- 422나 네트워크 실패면 필드 옆에 메시지를 보여 주고, 그 필드에 포커스를 주고, 다시 보낼 수 있게 합니다.
| 브라우저가 하던 일 | 이제 스크립트가 해야 할 일 |
|---|---|
| 탭에 로딩 상태 표시 | 로딩 상태를 표시하고 스크린 리더에 알리기 |
| 더블 클릭을 대체로 무해하게 만듦 | 요청이 진행 중이면 두 번째 제출 무시 |
| 오류 페이지 표시 | error.fields를 각 입력에 연결 |
| 네트워크 오류 페이지 표시 | 실패한 요청과 타임아웃 잡기 |
| 새 페이지로 포커스 이동 | 결과나 첫 번째 잘못된 필드로 포커스 이동 |
폼
필드는 세 개입니다. 필수 예/아니요, 선택 입력 의견, 스크립트가 채우는 페이지 주소입니다. 아래 정의를 시험해 보세요. Ikiform이 서버에서 강제하는 내용이므로 2,000자를 넘는 의견은 여기서도 422를 받습니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<fieldset>
<legend>Was this page helpful?</legend>
<label><input type="radio" name="helpful" value="yes" required /> Yes</label>
<label><input type="radio" name="helpful" value="no" /> No</label>
</fieldset>
<label for="comment">What was missing?</label>
<textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
<p id="comment-error" hidden></p>
<input type="hidden" name="page" />
<input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send</button>
<p id="feedback-status" role="status" tabindex="-1"></p>
</form>
<script type="module" src="/page-feedback.js"></script>폼은 스크립트 없이도 완전합니다. JavaScript가 꺼져 있거나 차단되면 일반 방식으로 POST하고, Ikiform이 303으로 응답하며, 독자는 _redirect 페이지로 이동합니다. 스크립트는 이를 개선할 뿐입니다.
스크립트
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;
pageInput.value = location.href;
function showFieldErrors(fields) {
for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
let first = null;
for (const [name, messages] of Object.entries(fields)) {
const control = form.elements.namedItem(name);
const hint = form.querySelector(`#${name}-error`);
if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
if (hint) {
hint.textContent = messages[0];
hint.hidden = false;
}
first ??= control instanceof RadioNodeList ? control[0] : control;
}
first?.focus();
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (sending) return;
const body = new FormData(form, event.submitter);
body.set("_idempotency", idempotencyKey);
sending = true;
button.setAttribute("aria-disabled", "true");
status.textContent = "Sending...";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body,
signal: AbortSignal.timeout(15000),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren(status);
status.textContent = "Thanks, that helps.";
status.focus();
return;
}
if (result.error.code === "challenge_required") {
form.submit();
return;
}
if (result.error.fields) {
status.textContent = "";
showFieldErrors(result.error.fields);
} else if (response.status === 429 && response.headers.has("Retry-After")) {
const wait = response.headers.get("Retry-After");
status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
} else {
status.textContent = result.error.message;
}
} catch {
status.textContent = "Couldn't reach the server. Check your connection and send again.";
} finally {
sending = false;
button.removeAttribute("aria-disabled");
}
});new FormData(form, event.submitter)는 이름이 있는 모든 컨트롤과, 클릭한 버튼에name과value가 있으면 그것까지 읽습니다. 폼에서 무엇이든 바꾸기 전에 만드세요.- 버튼에는
disabled가 아니라aria-disabled를 줍니다. 실제 차단은sending플래그가 하고, 버튼은 포커스를 받을 수 있게 남아 스크린 리더 사용자가 위치를 잃지 않습니다. Content-Type헤더는 없습니다.FormData본문이면 브라우저가 올바른 경계와 함께multipart/form-data를 설정합니다.Accept: application/json은 Ikiform이 JSON으로 응답하게 합니다.fetch호출은Sec-Fetch-Mode: cors를 보내므로 어차피 JSON이 선택되지만, 이 헤더가 의도를 분명히 합니다.- 페이지 로드마다 멱등성 키 하나를
_idempotency필드로 보냅니다. 독자가 보냈는데 네트워크가 응답을 잃어 다시 보내면, Ikiform은 두 번째 제출을 저장하는 대신 첫 응답을 재생합니다. 거부된 POST는 키를 차지하지 않으므로 고쳐서 다시 보내면 통과합니다.Idempotency-Key헤더도 같은 일을 하지만, 커스텀 헤더는 브라우저가 먼저 프리플라이트 요청을 보내게 합니다. 정확한 규칙은 JavaScript와 fetch에 있습니다. AbortSignal.timeout(15000)은 멈춘 요청을 끝없는 Sending... 대신 catch 분기로 보냅니다.- 상태 문구에는
role="status"가 있어 포커스를 옮기지 않고 텍스트가 읽힙니다. 성공하면 주변 필드가 사라지므로 포커스가 그곳으로 이동합니다.
폼에 사람 확인이 있을 때
폼의 Spam check가 Ikiform으로 설정되어 있으면 Turnstile 토큰이 없는 fetch POST는 403 challenge_required를 받습니다. 위 스크립트는 form.submit()을 호출해 이를 처리합니다. 이 메서드는 submit 리스너를 건너뛰고 일반 방식으로 POST하며, 일반 POST에 대해 Ikiform은 자체 짧은 확인 페이지를 보여 준 뒤 _redirect 페이지로 이어갑니다. 독자는 확인이 켜져 있을 때만 페이지를 떠납니다.
그때도 페이지에 머물려면 먼저 SDK의 challenge(formId)로 토큰을 받아 submit()에 넘기세요. 스팸 문서에 흐름이 있습니다.
JavaScript 없는 대안
fetch 이전의 요령은 숨겨진 iframe을 대상으로 지정해 응답이 그곳에 불러와지고 페이지는 그대로 있게 하는 것이었습니다.
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
...
</form>여전히 동작하지만 iframe에는 ikiform.com의 페이지가 들어 있고, 브라우저는 스크립트가 크로스 오리진 프레임을 읽지 못하게 합니다. 성공과 검증 오류를 구분할 수 없으므로 독자도 어느 쪽이든 피드백을 받지 못합니다. 스크립트를 정말 실행할 수 없는 곳에서만 쓰세요.
use:enhance, React Router의 <Form>은 페이지가 하이드레이션되기 전에는 일반 방식으로 POST하고, 그 뒤에는 새로고침 없이 POST합니다. 개요에서 각각을 보여 줍니다.작동 확인하기
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는
localhost도 추가하세요. 비워 두면 어디서든 POST를 받습니다. - 의견 없이 Yes를 제출하세요. 폼이 Thanks, that helps.로 바뀌고 Network 패널에
201이 표시됩니다. - 의견에 2,000자 넘게 붙여 넣고, 개발자 도구에서
maxlength를 제거한 뒤 보내세요. 의견 아래에 메시지가 나타나고 포커스가 그곳으로 이동합니다. - Send를 더블 클릭하세요. 대시보드에 제출이 두 개가 아니라 하나만 표시됩니다.
- JavaScript를 끄고 제출하세요.
_redirect페이지로 이동합니다. - 스로틀링을 Offline으로 설정하고 보내세요. 상태 문구가 서버에 연결할 수 없다고 알리고, Send가 다시 동작합니다.
주의할 점
| 문제 | 원인과 해결 |
|---|---|
| POST에서 필드가 빠짐 | FormData를 만들 때 disabled 상태였습니다. 비활성화된 컨트롤은 절대 전송되지 않습니다. 데이터를 먼저 만들거나 readonly로 입력을 잠그세요 |
400 invalid_body 또는 빈 제출 | 코드가 경계 없이 Content-Type: multipart/form-data를 직접 설정했습니다. 헤더를 삭제하세요 |
| 그래도 페이지가 새로고침됨 | 리스너가 preventDefault() 전에 예외를 던졌거나, 스크립트가 로드되기 전에 클릭했습니다. preventDefault()를 맨 먼저 호출하세요. 이른 클릭은 일반 방식으로 POST하며 그래도 동작합니다 |
response.json()이 예외를 던짐 | 요청이 HTML로 응답하는 곳으로 갔습니다. 대개 상대 action 때문입니다. 콘솔에서 form.action을 읽어 보세요. Ikiform URL이어야 합니다 |
403 challenge_required | 폼의 사람 확인이 켜져 있습니다. 위처럼 form.submit()으로 대체하거나 challenge()로 토큰을 받으세요 |
403 origin_not_allowed, 또는 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
질문
submit 리스너를 추가하고 첫 줄에서 event.preventDefault()를 호출한 뒤 fetch로 직접 데이터를 보내세요. Enter 키도 처리되도록 버튼의 click이 아니라 폼의 submit 이벤트를 들으세요.
아니요, 설정하면 안 됩니다. 브라우저가 경계 문자열과 함께 multipart/form-data를 설정합니다. 직접 쓴 헤더에는 경계가 없어 서버가 본문을 파싱할 수 없습니다.
네, submit 이벤트를 듣는다면 그렇습니다. 이 이벤트는 브라우저의 제약 조건 확인을 통과한 뒤에만 발생합니다. 코드에서 form.submit()을 호출하면 확인과 이벤트를 모두 건너뛰고, form.requestSubmit()은 둘 다 실행합니다.
요청이 진행 중일 때는 제출을 무시하고, 페이지 로드마다 멱등성 키 하나를 _idempotency 필드나 Idempotency-Key 헤더로 보내세요. 재시도가 그래도 Ikiform에 도달하면 제출을 다시 저장하는 대신 첫 응답을 재생합니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.