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
... | ... | @@ -38,6 +38,7 @@ |
| 38 | 38 |
} from '../_actions'; |
| 39 | 39 |
import { AdminMemberFormFields } from './admin-member-form-fields';
|
| 40 | 40 |
import styles from './admin-member-modal.module.scss'; |
| 41 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 41 | 42 |
|
| 42 | 43 |
interface AdminMemberCreateModalProps {
|
| 43 | 44 |
onClose: () => void; |
... | ... | @@ -156,7 +157,7 @@ |
| 156 | 157 |
</FoxButtonGroup> |
| 157 | 158 |
} |
| 158 | 159 |
> |
| 159 |
- <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 160 |
+ <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.formList}>
|
|
| 160 | 161 |
<FoxInput |
| 161 | 162 |
size="md" |
| 162 | 163 |
name="name" |
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
... | ... | @@ -22,6 +22,7 @@ |
| 22 | 22 |
import { updateAdminMemberAction } from '../_actions';
|
| 23 | 23 |
import { AdminMemberFormFields } from './admin-member-form-fields';
|
| 24 | 24 |
import styles from './admin-member-modal.module.scss'; |
| 25 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 25 | 26 |
|
| 26 | 27 |
interface AdminMemberEditModalProps {
|
| 27 | 28 |
member: AdminMember; |
... | ... | @@ -99,7 +100,7 @@ |
| 99 | 100 |
</FoxButtonGroup> |
| 100 | 101 |
} |
| 101 | 102 |
> |
| 102 |
- <form ref={formRef} action={formAction} className={styles.formList}>
|
|
| 103 |
+ <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.formList}>
|
|
| 103 | 104 |
{/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
|
| 104 | 105 |
<input type="hidden" name="id" value={member.id} />
|
| 105 | 106 |
|
--- app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-form-modal.tsx
... | ... | @@ -24,6 +24,7 @@ |
| 24 | 24 |
import { createBoardPostAction, updateBoardPostAction } from '../_actions';
|
| 25 | 25 |
import { BoardAttachmentField } from './board-attachment-field';
|
| 26 | 26 |
import { BoardContentField } from './board-content-field';
|
| 27 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 27 | 28 |
|
| 28 | 29 |
interface BoardPostFormModalProps {
|
| 29 | 30 |
boardType: BoardType; |
... | ... | @@ -115,7 +116,7 @@ |
| 115 | 116 |
</> |
| 116 | 117 |
} |
| 117 | 118 |
> |
| 118 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 119 |
+ <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
|
|
| 119 | 120 |
<input type="hidden" name="boardType" value={boardType} />
|
| 120 | 121 |
{!isCreate && post && <input type="hidden" name="id" value={post.id} />}
|
| 121 | 122 |
|
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
... | ... | @@ -21,6 +21,7 @@ |
| 21 | 21 |
import { BoardAttachmentField } from '../../_components/board-attachment-field';
|
| 22 | 22 |
import { BoardContentField } from '../../_components/board-content-field';
|
| 23 | 23 |
import { FieldError } from '../../_components/board-post-form-modal';
|
| 24 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 24 | 25 |
|
| 25 | 26 |
interface InquiryAnswerModalProps {
|
| 26 | 27 |
post: BoardPost; |
... | ... | @@ -81,7 +82,7 @@ |
| 81 | 82 |
</> |
| 82 | 83 |
} |
| 83 | 84 |
> |
| 84 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 85 |
+ <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
|
|
| 85 | 86 |
<input type="hidden" name="id" value={post.id} />
|
| 86 | 87 |
|
| 87 | 88 |
<Field label="질문유형"> |
--- 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 @@ |
| 10 | 10 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
| 11 | 11 |
import { createDecorationItemAction } from '../_actions';
|
| 12 | 12 |
import { DecorationItemFormFields } from './decoration-item-form-fields';
|
| 13 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 13 | 14 |
|
| 14 | 15 |
interface DecorationItemCreateModalProps {
|
| 15 | 16 |
/** 등록 버튼을 누른 시점의 활성 탭(유형) — 폼의 유형 기본값으로 쓴다. */ |
... | ... | @@ -70,7 +71,7 @@ |
| 70 | 71 |
</> |
| 71 | 72 |
} |
| 72 | 73 |
> |
| 73 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 74 |
+ <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
|
|
| 74 | 75 |
<p className="text-right text-body-sm text-danger"> |
| 75 | 76 |
* 는 필수 항목입니다. |
| 76 | 77 |
</p> |
--- 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 @@ |
| 10 | 10 |
import { INITIAL_DECORATION_ITEM_FORM_STATE } from '@/lib/domain/decoration-item-form';
|
| 11 | 11 |
import { updateDecorationItemAction } from '../_actions';
|
| 12 | 12 |
import { DecorationItemFormFields } from './decoration-item-form-fields';
|
| 13 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 13 | 14 |
|
| 14 | 15 |
interface DecorationItemEditModalProps {
|
| 15 | 16 |
item: DecorationItem; |
... | ... | @@ -68,7 +69,7 @@ |
| 68 | 69 |
</> |
| 69 | 70 |
} |
| 70 | 71 |
> |
| 71 |
- <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
|
|
| 72 |
+ <form id={FORM_ID} onSubmit={(event) => submitFormAction(event, formAction)} className="flex flex-col gap-4">
|
|
| 72 | 73 |
<input type="hidden" name="itemSn" value={item.itemSn} />
|
| 73 | 74 |
|
| 74 | 75 |
<p className="text-right text-body-sm text-danger"> |
--- app/(public)/login/_components/login-form.tsx
+++ app/(public)/login/_components/login-form.tsx
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
import { FoxEyeIcon, FoxEyeSlashIcon } from '@fox/core/icons';
|
| 7 | 7 |
import { login, type LoginFormState } from '../_actions';
|
| 8 | 8 |
import styles from '../login.module.scss'; |
| 9 |
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
|
|
| 9 | 10 |
|
| 10 | 11 |
const initialState: LoginFormState = {};
|
| 11 | 12 |
|
... | ... | @@ -35,7 +36,7 @@ |
| 35 | 36 |
const passwordId = useId(); |
| 36 | 37 |
|
| 37 | 38 |
return ( |
| 38 |
- <form action={formAction} className={styles.fields}>
|
|
| 39 |
+ <form onSubmit={(event) => submitFormAction(event, formAction)} className={styles.fields}>
|
|
| 39 | 40 |
<FoxInput |
| 40 | 41 |
size="lg" |
| 41 | 42 |
label="ID" |
+++ app/_hooks/submit-form-action.ts
... | ... | @@ -0,0 +1,24 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { startTransition, type FormEvent } from 'react'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * `<form action={formAction}>` 대신 쓰는 제출 핸들러. | |
| 7 | + * | |
| 8 | + * **React는 폼 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.** 그래서 검증 오류가 하나만 | |
| 9 | + * 나도 사용자가 채운 값이 전부 날아가고, 특히 `type="file"` 입력은 프로그램으로 되채울 수 없어 | |
| 10 | + * 다음 제출에서는 "이미지를 등록해 주세요"만 반복된다(꾸미기 아이템 등록에서 실제로 그랬다). | |
| 11 | + * | |
| 12 | + * 액션을 `action` 프롭에 맡기지 않고 transition 안에서 직접 부르면 그 리셋이 일어나지 않는다. | |
| 13 | + * 확인: 같은 액션을 두 방식으로 제출했을 때 `action={...}` 쪽만 입력값이 빈 문자열이 됐다. | |
| 14 | + * | |
| 15 | + * 성공 후 폼을 비워야 하는 화면은 각자 팝업을 닫으므로(다음에 열 때 새 폼이다) 리셋이 필요 없다. | |
| 16 | + */ | |
| 17 | +export function submitFormAction( | |
| 18 | + event: FormEvent<HTMLFormElement>, | |
| 19 | + formAction: (formData: FormData) => void | |
| 20 | +): void { | |
| 21 | + event.preventDefault(); | |
| 22 | + const formData = new FormData(event.currentTarget); | |
| 23 | + startTransition(() => formAction(formData)); | |
| 24 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?