NextJS와 함께하는 통합 생성 및 편집 폼

발행: (2025년 12월 7일 오후 08:32 GMT+9)
5 min read
원문: Dev.to

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 등) 저장 방식은 서버에 직접 업로드하는 것보다 안전합니다. 파일 식별자만 백엔드에 전송되므로 노출 위험이 줄어들고 파일 크기 제한도 피할 수 있습니다.

Back to Blog

관련 글

더 보기 »