프로필 편집
현재 정보가 미리 채워진 폼을 고쳐서 저장합니다. 이미지 선택, 글자 수 표시, 저장 후 알림까지 포함합니다.
프로필 편집
핵심 포인트
- 1
처음부터 현재 값을 채워 두기
현재 프로필을 defaultValues에 전달합니다. 실제 사이트에서는 여기에 서버에서 가져온 값이 들어갑니다.
- 2
아무것도 바꾸지 않았으면 저장할 수 없게 하기
isDirty(초깃값과 다른지)에 따라 저장 버튼을 누를 수 없게 합니다. 저장한 뒤에는 reset을 호출해 저장한 값을 새 초깃값으로 만듭니다.
- 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> ) }