편집 폼

관리 화면의 "상품 편집" 화면입니다. 서버에서 현재 내용을 읽어 와 폼에 채우고, 고친 부분을 저장합니다.

상품 편집

핵심 포인트

  1. 1

    읽어 오기 전까지는 임시 모양 보여 주기

    서버에서 데이터가 돌아오기까지의 1초 동안 회색 임시 모양(스켈레톤)을 보여 줍니다. 빈 입력란을 보여 주면 "아무것도 없다"고 오해합니다.

  2. 2

    받은 데이터를 폼의 초깃값으로 쓰기

    가져온 상품을 useForm의 values에 전달합니다. 데이터가 도착하면 입력란이 채워지고, "초깃값과 다른지(isDirty)"도 이것을 기준으로 비교합니다.

  3. 3

    고쳤다는 것을 알려 주기

    고치면 "저장하지 않은 변경 사항이 있습니다"를 표시합니다. 저장 중에는 버튼을 누를 수 없게 하고 "저장 중…"을 보여 주며, 끝나면 알림으로 알려 줍니다.

사용한 도구

누르면 제2부의 비교 페이지로

코드 고치기

코드를 고치면 오른쪽 미리보기가 바로 바뀝니다.

import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { toast } from "sonner"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"
import { Skeleton } from "./components/ui/skeleton"
import { Toaster } from "./components/ui/sonner"
import { fetchProduct, saveProduct } from "./api"
import { Form } from "lucide-react"

// 가져온 데이터를 기억해 두는 저장소(앱에 하나)
const queryClient = new QueryClient()

// 숫자 입력란: 입력란에서는 문자로 들어오므로 숫자로 바꾼 뒤에 검사한다(비어 있으면 '입력해 주세요')
const numberField = (label) =>
  z.preprocess(
    (value) => (value === "" ? undefined : Number(value)),
    z.number({ error: (issue) => (issue.input === undefined ? label + "을 입력해 주세요" : "숫자로 입력해 주세요") }).int("정수로 입력해 주세요"),
  )

// 숫자 입력란에 입력한 글자를 숫자로 바꿔서 폼에 넣는다(비어 있으면 그대로)
// 이렇게 하지 않으면 원래 값 110500을 다시 입력해도 '"110500"(문자)과 110500(숫자)은 다르다'가 되어 변경한 것으로 처리된다
const toNumber = (value) => (value === "" ? "" : Number(value))

// 입력 검사 규칙(Zod)
const schema = z.object({
  name: z.string().min(1, "상품명을 입력해 주세요"),
  price: numberField("가격").pipe(z.number().min(1, "1원 이상으로 입력해 주세요")),
  stock: numberField("재고 수량").pipe(z.number().min(0, "0 이상으로 입력해 주세요")),
  description: z.string().max(200, "200자 이내로 입력해 주세요"),
})

// 라벨, 입력란, 오류 메시지를 하나로 묶는다
function Field({ id, label, error, children }) {
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor={id}>{label}</Label>
      {children}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  )
}

// 불러오는 중에 보여 주는 회색 임시 모양(스켈레톤)
function FormSkeleton() {
  return (
    <div className="flex flex-col gap-5">
      <Skeleton className="h-6 w-40" />
      {[1, 2, 3].map((n) => (
        <div key={n} className="flex flex-col gap-2">
          <Skeleton className="h-4 w-16" />
          <Skeleton className="h-9 w-full" />
        </div>
      ))}
      <Skeleton className="h-24 w-full" />
    </div>
  )
}

function EditProduct() {
  // 서버에서 현재 상품을 가져온다(가져오는 동안 isPending이 true)
  const { data, isPending } = useQuery({ queryKey: ["product"], queryFn: fetchProduct })

  // 폼. values에 가져온 상품을 넘기면 도착했을 때 입력란에 들어간다
  const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
    resolver: zodResolver(schema),
    values: data,
  })

  // 저장(저장하는 동안 mutation.isPending이 true)
  const mutation = useMutation({
    mutationFn: saveProduct,
    onSuccess: (saved) => {
      queryClient.setQueryData(["product"], saved) // 기억해 둔 상품도 저장한 것으로 바꾼다
      reset(saved) // 저장한 값을 '처음 값'으로 만든다(isDirty가 false로 돌아간다)
      toast.success("저장되었습니다")
    },
  })

  // ① 불러오는 중(isPending이 true)이면 먼저 <FormSkeleton />만 반환한다
  if (isPending) {
    return <FormSkeleton />
  }
  return (
    <form onSubmit={handleSubmit((values) => mutation.mutate(values))} className="flex flex-col gap-5">
      <div className="flex items-center gap-3">
        <h1 className="text-lg font-semibold">상품 편집</h1>
        {/* ② 무언가 바꿨을 때(isDirty가 true)만 아래 배지를 보여 준다 */}
        {isDirty && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-700">저장하지 않은 변경 사항이 있습니다</span>}
      </div>

      <Field id="name" label="상품명" error={errors.name?.message}>
        <Input id="name" {...register("name")} />
      </Field>
      <div className="grid grid-cols-2 gap-4">
        <Field id="price" label="가격(원)" error={errors.price?.message}>
          <Input id="price" inputMode="numeric" {...register("price", { setValueAs: toNumber })} />
        </Field>
        <Field id="stock" label="재고 수량" error={errors.stock?.message}>
          <Input id="stock" inputMode="numeric" {...register("stock", { setValueAs: toNumber })} />
        </Field>
      </div>
      <Field id="description" label="설명" error={errors.description?.message}>
        <textarea
          id="description"
          rows={3}
          className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
          {...register("description")}
        />
      </Field>

      <div className="flex justify-end gap-2 border-t pt-4">
        {/* ③ 아무것도 바꾸지 않았거나 저장 중이면 누를 수 없다(disabled) */}
        {/* ⑤ 누르면 처음 값으로 되돌린다(onClick에서 reset) */}
        <Button type="button" variant="outline" disabled={!isDirty || mutation.isPending} onClick={() => reset()}>
          변경 취소
        </Button>
        {/* ③ 아무것도 바꾸지 않았거나 저장 중이면 누를 수 없다(disabled) */}
        <Button type="submit" disabled={!isDirty || mutation.isPending}>
          {/* ④ 저장 중(mutation.isPending이 true)이면 '저장 중…', 그 밖에는 '저장' */}
          {mutation.isPending ? "저장 중…" : "저장"}
        </Button>
      </div>
    </form >
  )
}

export default function App() {
  return (
    // 앱 전체를 QueryClientProvider로 감싸면 안에서 useQuery를 쓸 수 있다
    <QueryClientProvider client={queryClient}>
      <div className="mx-auto my-6 w-full max-w-xl rounded-xl border bg-card p-6 text-card-foreground">
        <EditProduct />
      </div>
      <Toaster position="bottom-right" />
    </QueryClientProvider>
  )
}