프로필 편집

현재 정보가 미리 채워진 폼을 고쳐서 저장합니다. 이미지 선택, 글자 수 표시, 저장 후 알림까지 포함합니다.

프로필 편집

핵심 포인트

  1. 1

    처음부터 현재 값을 채워 두기

    현재 프로필을 defaultValues에 전달합니다. 실제 사이트에서는 여기에 서버에서 가져온 값이 들어갑니다.

  2. 2

    아무것도 바꾸지 않았으면 저장할 수 없게 하기

    isDirty(초깃값과 다른지)에 따라 저장 버튼을 누를 수 없게 합니다. 저장한 뒤에는 reset을 호출해 저장한 값을 새 초깃값으로 만듭니다.

  3. 3

    저장되면 알림으로 알려 주기

    화면 오른쪽 아래에 "저장했습니다"를 표시합니다(sonner의 toast). 화면이 바뀌지 않는 작업은 알림이 없으면 저장됐는지 알 수 없습니다.

사용한 도구

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

코드 고치기

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

import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { toast } from "sonner"
import { Avatar, AvatarFallback, AvatarImage } from "./components/ui/avatar"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"
import { Toaster } from "./components/ui/sonner"

// ① 입력 검사 규칙(Zod)
const schema = z.object({
  name: z.string().min(1, "이름을 입력해 주세요"),
  username: z.string().regex(/^[a-z0-9_]+$/, "영문 소문자, 숫자, _만 사용할 수 있습니다"),
  bio: z.string().max(160, "160자 이내로 입력해 주세요"),
})
type Profile = z.infer<typeof schema>

// 현재 프로필(실제로는 서버에서 가져온다)
const current: Profile = {
  name: "김민준",
  username: "minjun",
  bio: "React를 공부하고 있습니다.",
}

// 진짜 서버 대신: 잠깐 기다린다
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

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

export default function App() {
  // 선택한 이미지(아직 선택하지 않았으면 null)
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null)

  // ② defaultValues: 처음부터 현재 값을 넣어 둔다
  // isDirty: 처음 값에서 하나라도 바뀌면 true(바뀌지 않았으면 저장할 수 없다)
  const { register, handleSubmit, watch, reset, formState: { errors, isDirty, isSubmitting } } = useForm<Profile>({
    resolver: zodResolver(schema),
    defaultValues: current,
  })

  // 지금 입력란에 들어 있는 값(입력할 때마다 바뀐다)
  const name = watch("name")
  const bio = watch("bio")

  async function onSubmit(values: Profile) {
    await wait(800)
    // 저장한 값을 '처음 값'으로 만든다(isDirty가 false로 돌아간다)
    reset(values)
    // ③ 저장되었음을 오른쪽 아래 알림으로 알린다(sonner)
    toast.success("저장되었습니다")
  }

  // 이미지를 선택하면 프로필 사진으로 보여 준다(브라우저 안에서만 미리 보기. 업로드는 하지 않는다)
  function onPickImage(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (file) setAvatarUrl(URL.createObjectURL(file))
  }

  return (
    <div className="mx-auto my-6 w-full max-w-md rounded-xl border bg-card p-6">
      <p className="text-lg font-semibold">프로필</p>
      <p className="mt-1 text-sm text-muted-foreground">다른 사람에게 보이는 정보입니다</p>

      <form onSubmit={handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4">
        <div className="flex items-center gap-4">
          <Avatar className="size-16">
            {avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
            <AvatarFallback className="bg-linear-to-br from-sky-400 to-indigo-500 text-xl font-semibold text-white">{name.slice(0, 1)}</AvatarFallback>
          </Avatar>
          <div>
            {/* 보이지 않는 '파일 선택' 입력란을 버튼 모양의 label로 누를 수 있게 한다 */}
            <Button type="button" variant="outline" size="sm" asChild>
              <label htmlFor="avatar" className="cursor-pointer">프로필 사진 변경</label>
            </Button>
            <input id="avatar" type="file" accept="image/*" className="hidden" onChange={onPickImage} />
            <p className="mt-1 text-xs text-muted-foreground">JPG, PNG, 최대 2MB</p>
          </div>
        </div>

        <Field id="name" label="이름" error={errors.name?.message}>
          <Input id="name" {...register("name")} />
        </Field>
        <Field id="username" label="사용자 이름" error={errors.username?.message}>
          {/* 왼쪽에 '@'를 붙인 입력란 */}
          <div className="flex rounded-md border">
            <span className="flex items-center border-r bg-muted px-3 text-sm text-muted-foreground">@</span>
            <Input id="username" className="rounded-l-none border-0 shadow-none" {...register("username")} />
          </div>
        </Field>
        <Field id="bio" label="자기소개" error={errors.bio?.message}>
          <textarea
            id="bio"
            rows={3}
            className="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
            {...register("bio")}
          />
          <p className="text-right text-xs text-muted-foreground">{bio.length} / 160</p>
        </Field>

        <div className="flex justify-end gap-2">
          <Button type="button" variant="outline" disabled={!isDirty} onClick={() => reset()}>
            되돌리기
          </Button>
          <Button type="submit" disabled={!isDirty || isSubmitting}>
            {isSubmitting ? "저장 중…" : "저장"}
          </Button>          
        </div>
      </form>

      <Toaster position="bottom-right" />
    </div>
  )
}