임동욱 임동욱 08-19
fix: 저장 실패 시 입력값이 통째로 날아가던 문제
**React는 폼 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.** 그래서 검증 오류가 하나만
나도 사용자가 채운 값이 전부 사라졌다. 특히 `type="file"`은 프로그램으로 되채울 수 없어, 한 번
실패하면 그 뒤로는 **"썸네일 이미지를 등록해 주세요."만 반복되는 상태에 갇힌다** — 꾸미기 아이템
등록에서 실제로 그랬다(다른 항목이 틀려 한 번 실패 → 파일 선택까지 지워짐 → 이후 계속 이미지
오류). 즉 사용자에게는 "고칠수록 더 안 되는" 화면이었다.

확인: 같은 Server Action을 두 방식으로 제출해 비교했다. `action={formAction}`은 입력값이 빈
문자열이 됐고, `onSubmit` + `startTransition(() => formAction(fd))`은 값이 그대로 남았다.
실제 로그인 폼으로도 검증 — 백엔드가 "아이디 또는 비밀번호가 올바르지 않습니다."를 돌려준
뒤에도 입력값이 유지된다(종전에는 다시 타이핑해야 했다).

같은 결함이 있던 폼 7개를 한 번에 바꿨다(관리자 등록·수정, 게시글, 문의 답변, 아이템 등록·수정,
로그인). 학생 조회 팝업은 hidden id 하나뿐이라 잃을 값이 없어 그대로 뒀다.

이유를 한곳에 적어 두려고 `submitFormAction`으로 뽑았다 — 세 줄짜리라 각자 쓰면 왜 `action`을
안 쓰는지가 사라진다.

Co-Authored-By: Claude Opus 5 
@8d18d26eeedf40f854887ab47fa7f5642ef414b4
app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
@@ -38,6 +38,7 @@
 } from '../_actions';
 import { AdminMemberFormFields } from './admin-member-form-fields';
 import styles from './admin-member-modal.module.scss';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 interface AdminMemberCreateModalProps {
   onClose: () => void;
@@ -156,7 +157,7 @@
         </FoxButtonGroup>
       }
     >
-      <form ref={formRef} action={formAction} className={styles.formList}>
+      <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.formList}>
         <FoxInput
           size="md"
           name="name"
app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
@@ -22,6 +22,7 @@
 import { updateAdminMemberAction } from '../_actions';
 import { AdminMemberFormFields } from './admin-member-form-fields';
 import styles from './admin-member-modal.module.scss';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 interface AdminMemberEditModalProps {
   member: AdminMember;
@@ -99,7 +100,7 @@
         </FoxButtonGroup>
       }
     >
-      <form ref={formRef} action={formAction} className={styles.formList}>
+      <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.formList}>
         {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
         <input type="hidden" name="id" value={member.id} />
 
app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
--- app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
@@ -24,6 +24,7 @@
 import { createBoardPostAction, updateBoardPostAction } from '../_actions';
 import { BoardAttachmentField } from './board-attachment-field';
 import { BoardContentField } from './board-content-field';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 interface BoardPostFormModalProps {
   boardType: BoardType;
@@ -115,7 +116,7 @@
         </>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
         <input type="hidden" name="boardType" value={boardType} />
         {!isCreate && post && <input type="hidden" name="id" value={post.id} />}
 
app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
@@ -21,6 +21,7 @@
 import { BoardAttachmentField } from '../../_components/board-attachment-field';
 import { BoardContentField } from '../../_components/board-content-field';
 import { FieldError } from '../../_components/board-post-form-modal';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 interface InquiryAnswerModalProps {
   post: BoardPost;
@@ -81,7 +82,7 @@
         </>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
         <input type="hidden" name="id" value={post.id} />
 
         <Field label="질문유형">
app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-create-modal.tsx
@@ -10,6 +10,7 @@
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
 import { createDecorationItemAction } from '../_actions';
 import { DecorationItemFormFields } from './decoration-item-form-fields';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 interface DecorationItemCreateModalProps {
   /** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */
@@ -70,7 +71,7 @@
         </>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
         <p className="text-right text-body-sm text-danger">
           * 는 필수 항목입니다.
         </p>
app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-edit-modal.tsx
@@ -10,6 +10,7 @@
 import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
 import { updateDecorationItemAction } from '../_actions';
 import { DecorationItemFormFields } from './decoration-item-form-fields';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 interface DecorationItemEditModalProps {
   item: DecorationItem;
@@ -68,7 +69,7 @@
         </>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
+      <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
         <input type="hidden" name="itemSn" value={item.itemSn} />
 
         <p className="text-right text-body-sm text-danger">
app/(public)/login/_components/login-form.tsx
--- app/(public)/login/_components/login-form.tsx
+++ app/(public)/login/_components/login-form.tsx
@@ -6,6 +6,7 @@
 import { FoxEyeIcon, FoxEyeSlashIcon } from '@fox/core/icons';
 import { login, type LoginFormState } from '../_actions';
 import styles from '../login.module.scss';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
 
 const initialState: LoginFormState = {};
 
@@ -35,7 +36,7 @@
   const passwordId = useId();
 
   return (
-    <form action={formAction} className={styles.fields}>
+    <form onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}>
       <FoxInput
         size="lg"
         label="ID"
 
app/_hooks/submit-form-action.ts (added)
+++ app/_hooks/submit-form-action.ts
@@ -0,0 +1,24 @@
+'use client';
+
+import { startTransition, type FormEvent } from 'react';
+
+/**
+ * `<form action={formAction}>` 대신 쓰는 제출 핸들러.
+ *
+ * **React는 폼 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.** 그래서 검증 오류가 하나만
+ * 나도 사용자가 채운 값이 전부 날아가고, 특히 `type="file"` 입력은 프로그램으로 되채울 수 없어
+ * 다음 제출에서는 "이미지를 등록해 주세요"만 반복된다(꾸미기 아이템 등록에서 실제로 그랬다).
+ *
+ * 액션을 `action` 프롭에 맡기지 않고 transition 안에서 직접 부르면 그 리셋이 일어나지 않는다.
+ * 확인: 같은 액션을 두 방식으로 제출했을 때 `action={...}` 쪽만 입력값이 빈 문자열이 됐다.
+ *
+ * 성공 후 폼을 비워야 하는 화면은 각자 팝업을 닫으므로(다음에 열 때 새 폼이다) 리셋이 필요 없다.
+ */
+export function submitFormAction(
+  event: FormEvent<HTMLFormElement>,
+  formAction: (formData: FormData) => void
+): void {
+  event.preventDefault();
+  const formData = new FormData(event.currentTarget);
+  startTransition(() => formAction(formData));
+}
Add a comment
List