プロフィール編集
今の値が入ったフォームを直して保存します。画像の選択、文字数の表示、保存したあとの通知までを作ります。
プロフィール編集
ここがポイント
- 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: "taro", bio: "React を勉強中です。", } // 本物のサーバーの代わり:少し待つ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) // ラベル・入力欄・エラーの文を、1つのまとまりにする 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:最初の値から1つでも変わったら 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> ) }