プロフィール編集

今の値が入ったフォームを直して保存します。画像の選択、文字数の表示、保存したあとの通知までを作ります。

プロフィール編集

ここがポイント

  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: "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>
  )
}