NextJS와 함께하는 통합 생성 및 편집 폼
Source: Dev.to
소개
이 가이드에서는 새 데이터를 추가하고 기존 데이터를 업데이트하는 두 가지 작업을 모두 처리할 수 있는 통합 폼을 만들겠습니다. 하나의 폼을 사용하면 코드 중복이 줄어들고, 유지보수가 쉬워지며, 데이터 처리와 검증의 일관성을 보장하고, 사용자 경험이 향상되며, 개발 속도가 빨라집니다.
우리는 지점(branches) 을 추가하고 편집하는 폼을 만드는 과정을 단계별로 살펴볼 것입니다.
Branch 인스턴스 타입
export type BranchInstance = {
id: string;
branch_name: string;
location: string;
img: string;
google_coordinate: string;
};
검증 스키마 (Zod)
import { z } from "zod";
// validator
export const BranchSchema = z.object({
branch_name: z.string().min(1, "Branch name is required"),
location: z.string().min(1, "Location is required"),
google_coordinate: z.string().min(1, "Google Map embed link is required"),
img: z.string().min(1, "One Image of the place is required"),
});
// Infer the TypeScript type from the Zod schema
type BranchSchemaType = z.infer<typeof BranchSchema>;
// Errors type
export type zodBranchErrorsType = {
[K in keyof BranchSchemaType]?: string[];
};
저장 응답 타입
export type BranchSaveResponse = {
status: number;
data: BranchInstance;
message: string;
errors?: {
fieldErrors?: zodBranchErrorsType;
formErrors?: string[];
};
};
// Generic reusable version
export type SaveResponse<T> = {
status: number;
data: T;
message: string;
errors?: {
fieldErrors?: Record<string, string[]>;
formErrors?: string[];
};
};
export type BranchSaveResponse = SaveResponse<BranchInstance>;
FormData를 일반 객체로 변환하기
export function formDataToObject(fd: FormData) {
// This object will hold the final key‑value pairs
const out: Record<string, any> = {};
// This set keeps track of all keys that are arrays (ending with "[]")
const arrayKeys = new Set<string>();
// Loop through all key‑value pairs in the FormData
for (const [rawKey, value] of fd.entries()) {
// Check if the field name ends with "[]", which means it's an array field
const isArray = rawKey.endsWith("[]");
// Remove the "[]" from the key for cleaner object property names
const key = isArray ? rawKey.slice(0, -2) : rawKey;
// Remember that this key is meant to represent an array
if (isArray) arrayKeys.add(key);
// If this key already exists in the object, we need to merge the new value
if (key in out) {
const prev = out[key];
// If it's already an array, add the new value to it;
// otherwise, convert the previous single value into an array
out[key] = Array.isArray(prev) ? [...prev, value] : [prev, value];
} else {
out[key] = value;
}
}
// Ensure that all keys marked as arrays are stored as arrays,
// even if only one value was present in the FormData
for (const key of arrayKeys) {
const v = out[key];
out[key] = Array.isArray(v) ? v : v === undefined ? [] : [v];
}
// Return the final, cleaned‑up object
return out;
}
재사용 가능한 Input 컴포넌트
export function InputField({
label,
name,
defaultValue,
errors,
placeholder,
type = "text", // default is text
}: {
label: string;
name: string;
defaultValue?: string | number | null;
errors?: string[];
placeholder?: string;
type?: "text" | "number" | "email" | "password";
}) {
return (
<div className="mb-4">
<label htmlFor={name} className="block text-sm font-medium text-gray-700">
{label}
</label>
<input
id={name}
name={name}
type={type}
className={`mt-1 block w-full rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
errors?.length ? "border-red-500" : "border-gray-300"
}`}
defaultValue={defaultValue as string | number}
placeholder={placeholder}
/>
{errors?.map((error, index) => (
<p key={index} className="mt-1 text-sm text-red-600">
{error}
</p>
))}
</div>
);
}
이미지 업로더 컴포넌트
import { useState, useRef, useCallback } from "react";
import toast from "react-hot-toast";
const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
/* SVG content omitted for brevity */
/>
);
const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg
className={className}
/* SVG content omitted for brevity */
/>
);
function ImageUploader({
label,
name,
errors,
initial,
}: {
label: string;
name: string;
errors: string[];
initial?: string; // if provided, the image is in update state
}) {
const [image, setImage] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [imageId, setImageId] = useState<string | null>(null);
// Used only if `initial` is provided
const [markedDeletedId, setMarkedDeletedId] = useState<string[]>([]);
const processFile = useCallback(async (file: File) => {
if (file && file.type.startsWith("image/")) {
/** upload to Appwrite */
toast.loading("Uploading main image", { id: "upload" });
// ...upload logic here...
}
}, []);
// Component UI (simplified)
return (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">{label}</label>
{/* UI for drag‑and‑drop, preview, delete, etc. */}
{/* Errors */}
{errors.map((e, i) => (
<p key={i} className="mt-1 text-sm text-red-600">
{e}
</p>
))}
{/* Hidden inputs to send image ID and deleted IDs to the server */}
<input type="hidden" name={`${name}_id`} value={imageId ?? ""} />
{markedDeletedId.map((id) => (
<input key={id} type="hidden" name={`${name}_deleted_ids[]`} value={id} />
))}
</div>
);
}
Note: 이와 같은 파일(이미지, PDF, DOCX 등) 저장 방식은 서버에 직접 업로드하는 것보다 안전합니다. 파일 식별자만 백엔드에 전송되므로 노출 위험이 줄어들고 파일 크기 제한도 피할 수 있습니다.