fix: 폼 에러 시 사용자 입력값 유지 (회원가입, 업체 인증 신청)

useActionState 사용하는 폼에서 서버 에러 반환 시 입력값이 초기화되는 문제 수정.
서버 액션이 에러 시 submitted values를 함께 반환하고, 폼 input에 defaultValue 바인딩.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-03-30 18:50:23 +09:00
parent e674b90d5a
commit 49ed21a768
4 changed files with 54 additions and 14 deletions
+16 -2
View File
@@ -37,6 +37,12 @@ export type RegisterFormState = {
success: boolean;
error?: string;
fieldErrors?: Record<string, string[]>;
values?: {
email: string;
name: string;
phone: string;
role: string;
};
};
const ROLE_TO_PROFILE_TYPE: Record<string, ProfileType> = {
@@ -61,11 +67,19 @@ export async function registerAction(
kakaoNotification: formData.get('kakaoNotification') === 'on',
};
const submittedValues = {
email: String(raw.email || ''),
name: String(raw.name || ''),
phone: String(raw.phone || ''),
role: String(raw.role || ''),
};
const parsed = registerSchema.safeParse(raw);
if (!parsed.success) {
return {
success: false,
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
values: submittedValues,
};
}
@@ -74,7 +88,7 @@ export async function registerAction(
const existing = await prisma.user.findFirst({ where: { emailNormalized } });
if (existing) {
return { success: false, error: '이미 가입된 이메일입니다' };
return { success: false, error: '이미 가입된 이메일입니다', values: submittedValues };
}
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
@@ -161,7 +175,7 @@ export async function registerAction(
'code' in err &&
(err as { code: string }).code === 'P2002'
) {
return { success: false, error: '이미 가입된 이메일입니다' };
return { success: false, error: '이미 가입된 이메일입니다', values: submittedValues };
}
throw err;
}
+4 -1
View File
@@ -57,7 +57,7 @@ export default function RegisterPage() {
key={role.value}
className="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-ink/5 p-4 transition-all hover:border-warm-400 hover:bg-warm-50"
>
<input type="radio" name="role" value={role.value} required className="h-4 w-4" />
<input type="radio" name="role" value={role.value} required className="h-4 w-4" defaultChecked={state.values?.role === role.value} />
<div>
<div className="font-medium text-ink">{role.label}</div>
<div className="text-sm text-ink-muted">{role.desc}</div>
@@ -79,6 +79,7 @@ export default function RegisterPage() {
name="name"
type="text"
required
defaultValue={state.values?.name}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
/>
{state.fieldErrors?.name && (
@@ -95,6 +96,7 @@ export default function RegisterPage() {
name="phone"
type="tel"
placeholder="010-1234-5678"
defaultValue={state.values?.phone}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
/>
{state.fieldErrors?.phone && (
@@ -111,6 +113,7 @@ export default function RegisterPage() {
name="email"
type="email"
required
defaultValue={state.values?.email}
className="w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink placeholder:text-ink-muted focus:border-warm-500 focus:outline-none focus:ring-2 focus:ring-warm-500/20"
/>
{state.fieldErrors?.email && (
+27 -7
View File
@@ -10,10 +10,22 @@ const prisma = createPrismaClient();
const VALID_VENDOR_TYPES = new Set<string>(['DEMOLITION', 'INTERIOR', 'ACQUISITION']);
export type VendorActionState = {
success: boolean;
message: string;
values?: {
businessName: string;
vendorType: string;
contactName: string;
businessRegistrationNumber: string;
coverageRegionCodes: string[];
};
} | null;
export async function applyVendorCertificationAction(
_prev: { success: boolean; message: string } | null,
_prev: VendorActionState,
formData: FormData,
): Promise<{ success: boolean; message: string }> {
): Promise<VendorActionState> {
const session = await auth();
if (!session?.user?.dbId) {
return { success: false, message: '로그인이 필요합니다.' };
@@ -25,17 +37,25 @@ export async function applyVendorCertificationAction(
const businessRegistrationNumber = formData.get('businessRegistrationNumber') as string | null;
const coverageRegionCodes = formData.getAll('coverageRegions') as string[];
const submittedValues = {
businessName: businessName || '',
vendorType: vendorType || '',
contactName: contactName || '',
businessRegistrationNumber: businessRegistrationNumber || '',
coverageRegionCodes,
};
if (!vendorType || !VALID_VENDOR_TYPES.has(vendorType)) {
return { success: false, message: '업체 유형을 선택해주세요.' };
return { success: false, message: '업체 유형을 선택해주세요.', values: submittedValues };
}
if (!businessName?.trim()) {
return { success: false, message: '업체명을 입력해주세요.' };
return { success: false, message: '업체명을 입력해주세요.', values: submittedValues };
}
if (!contactName?.trim()) {
return { success: false, message: '담당자명을 입력해주세요.' };
return { success: false, message: '담당자명을 입력해주세요.', values: submittedValues };
}
if (coverageRegionCodes.length === 0) {
return { success: false, message: '서비스 가능 지역을 하나 이상 선택해주세요.' };
return { success: false, message: '서비스 가능 지역을 하나 이상 선택해주세요.', values: submittedValues };
}
const result = await applyVendorCertificationService(prisma, {
@@ -48,7 +68,7 @@ export async function applyVendorCertificationAction(
});
if (!result.ok) {
return { success: false, message: result.error.message ?? '인증 신청에 실패했습니다.' };
return { success: false, message: result.error.message ?? '인증 신청에 실패했습니다.', values: submittedValues };
}
revalidatePath('/vendors');
+7 -4
View File
@@ -1,7 +1,7 @@
'use client';
import { useActionState } from 'react';
import { applyVendorCertificationAction } from './actions';
import { applyVendorCertificationAction, type VendorActionState } from './actions';
const REGION_MAP: Record<string, string> = {
'강남권 (역삼/선릉/논현)': 'KR.BETA.GANGNAM_CORE',
@@ -10,10 +10,8 @@ const REGION_MAP: Record<string, string> = {
const REGIONS = Object.keys(REGION_MAP);
type ActionState = { success: boolean; message: string } | null;
export default function VendorApplicationForm() {
const [state, formAction, isPending] = useActionState<ActionState, FormData>(
const [state, formAction, isPending] = useActionState<VendorActionState, FormData>(
applyVendorCertificationAction,
null,
);
@@ -43,6 +41,7 @@ export default function VendorApplicationForm() {
type="text"
placeholder="예: (주)클린철거"
required
defaultValue={state?.values?.businessName}
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
@@ -51,6 +50,7 @@ export default function VendorApplicationForm() {
<select
name="vendorType"
required
defaultValue={state?.values?.vendorType}
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
>
<option value=""></option>
@@ -69,6 +69,7 @@ export default function VendorApplicationForm() {
type="text"
placeholder="담당자 이름"
required
defaultValue={state?.values?.contactName}
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
@@ -78,6 +79,7 @@ export default function VendorApplicationForm() {
name="businessRegistrationNumber"
type="text"
placeholder="123-45-67890"
defaultValue={state?.values?.businessRegistrationNumber}
className="mt-1 w-full rounded-xl border border-ink/10 bg-white/70 px-4 py-3 text-sm text-ink focus:border-warm-500 focus:ring-2 focus:ring-warm-500/20 focus:outline-none"
/>
</div>
@@ -92,6 +94,7 @@ export default function VendorApplicationForm() {
type="checkbox"
name="coverageRegions"
value={REGION_MAP[region]}
defaultChecked={state?.values?.coverageRegionCodes?.includes(REGION_MAP[region]!)}
className="rounded border-ink/10 accent-warm-500"
/>
{region}