😊

react shadcn(hook form zod SWR) でのフォームとfetchの実装の備忘録

に公開

はじめに

こんにちは.
T.Miuraです.

みなさんいかがお過ごしでしょうか?
私は元気にやっております.

モダンフロントのお仕事がない弊社ですが文句を言っていても仕方がないの個人でReactを触っています.そろそろ本格的にNext.jsに入りますヨ
(シンプルに趣味なだけなんですが...)

本ブログではここ最近学んだことの備忘録です.
フロント初めて大体2ヶ月程度でアホなこと言っている姿を笑って下さい.

興奮してきたな

題材としてpokeAPIを使用したfetchです
詳しいコンポーネント分けはしていません!!!!!!

動く完成品です

https://react-practices-xi.vercel.app/

環境構築

技術スタック

Docker
vite

React
shadcn
react-hook-form
zod
SWR

Dockerfile

FROM node:20-alpine3.20
WORKDIR /usr/src/app

RUN apk update && npm update && apk add \
    git \
    vim \
    tree \
    curl \
    wget

docker-compose.yml

services:
  node:
    build: .
    environment:
      - NODE_ENV=development
    volumes:
      - ./:/usr/src/app
    ports:
      - '3000:3000'
    tty: true

コンテナの世界へ入門

docker compose up -d
docker compose exec node sh

viteのプロジェクト作成

npm create vite@latest

react-tscを選択

cd ~作成したプロジェクトへ~
npm install

ライブラリのインストール

tailwindcssのインストール

npm install @tailwindcss/vite

app.cssにtailwindcssをインポート

@import "tailwindcss";

shadcnのインストール

npx shadcn@latest init
npm install -D @types/node
npx shadcn@latest add form

swrのインストール

npm install swr

react-hook-formのインストール

npm install react-hook-form

初期設定

私はここでvite.config.tsを編集しました.
詳しくはshadcnのインストールガイドを参照してください.
docker-compose.ymlでポートを3000にしているのでvite.config.tsでも3000にします.

import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react-swc"
import { defineConfig } from "vite"
 
export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
  //ここ追加することでdocker コンテナからlocalhost:3000でアクセスできるようになります
  server: {
    port: 3000,
    host: '0.0.0.0',
  },
})

tsconfig.jsonの設定

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  //ここから追加
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
  //ここまで追加
}

tsconfig.app.jsonの設定

{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
//ここから追加
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
//ここまで追加
    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"]
}

さぁ奇跡の瞬間

npm run dev

React icon が見えれば成功ですね

フォーム実装

それでは実装していきます

まずはzodから仕留めます

zod でバリデーションスキーマの作成

import { z } from "zod";

export const formSchema = z.object({
  pokeId: z.string().regex(/^(?:[1-9][0-9]{0,2}|10[0-1][0-9]|102[0-5])$/, { message: "1から1025までの数字を入力してください" }),
});

そして次はuseFormのカスタムフックを作成してやります.

先ほど作成したzodのスキーマをreact-hook-formにresolverとして渡します

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { formSchema } from "./validation";

const useFormWithValidation = () => {
  const form = useForm<z.infer<typeof formSchema>>({
    //ここのmodeでいつバリデーションを行うかを指定しますよ
    //onChangeでは入力した度にバリデーションを行うようになりますよ
    //入力のたびに赤文字でると興奮しちゃうので今回はonSubmitで行きましょい
    mode: "onSubmit",
    resolver: zodResolver(formSchema),
    defaultValues: {
      pokeId: "",
    },
  });

  return form;
}

export default useFormWithValidation;

この段階でフォーム大枠が完成しましたね.

次はshadcnのコンポーネントを使用してフォームを作成します.

import "./App.css";
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import type{ SubmitHandler } from "react-hook-form";

import useFormWithValidation from "./useFormWithValidation";


type formInputs = {
    pokeId: string;
};

function App() {
  const form = useFormWithValidation();
  const onSubmit: SubmitHandler<formInputs> = (data) => {
    console.log(data);
  };
  return (
    <>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
          <FormField
            control={form.control}
            name="pokeId"
            render={({ field }) => (
              <FormItem>
                <FormLabel>ポケモンのID</FormLabel>
                <FormControl>
                  <Input placeholder="ポケモンのIDを入力してください" {...field} />
                </FormControl>
                <FormDescription>
                  ポケモンのIDを入力してください
                </FormDescription>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit">送信</Button>
        </form>
      </Form>
    </>
  );
}

export default App;

データフェッチング

お次はuseSWRの番です覚悟しやがれ

まずはuseSWRを使用したカスタムフックを作成します

import useSWR from "swr";

export type Poke = {
  id: number;
  name: string;
  image: string;
  type: string[];
  height: number;
  weight: number;
  abilities: string[];
};

// PokeAPIのレスポンス型を定義
type PokeAPIResponse = {
  id: number;
  name: string;
  sprites: {
    front_default: string;
  };
  types: { type: { name: string } }[];
  height: number;
  weight: number;
  abilities: { ability: { name: string } }[];
};

export const useFetchPoke = (id:  string) => {

  const fetcher = (url: string) => fetch(url).then((res) => res.json());

  const { data, error, isLoading } = useSWR(
    `https://pokeapi.co/api/v2/pokemon/${id}`,
    fetcher
  );


  const mapToPoke = (data: PokeAPIResponse): Poke => ({
    id: data.id,
    name: data.name,
    image: data.sprites?.front_default ?? "",
    type: data.types?.map((t) => t.type.name) ?? [],
    height: data.height,
    weight: data.weight,
    abilities: data.abilities?.map((a) => a.ability.name) ?? [],
  });

  return {
    poke: data ? mapToPoke(data) : null,
    isError: error,
    isLoading
  };

};

そしてそれを作成したフォームで読み込ませればあら不思議

import "./App.css";
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import type{ SubmitHandler } from "react-hook-form";
import { useState } from "react";
import useFormWithValidation from "./useFormWithValidation";
import { useFetchPoke } from "./useFetchPoke";

type formInputs = {
    pokeId: string;
};

function App() {
  const [pokeId, setPokeId] = useState<string>("");
  const form = useFormWithValidation();

  const { poke, isError, isLoading } = useFetchPoke(pokeId);

  const onSubmit: SubmitHandler<formInputs> = (data) => {
    setPokeId(data.pokeId);
  };
  return (
    <>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
          <FormField
            control={form.control}
            name="pokeId"
            render={({ field }) => (
              <FormItem>
                <FormLabel>ポケモンのID</FormLabel>
                <FormControl>
                  <Input placeholder="ポケモンのIDを入力してください" {...field} />
                </FormControl>
                <FormDescription>
                  ポケモンのIDを入力してください
                </FormDescription>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit">送信</Button>
        </form>
      </Form>
      <p>{poke?.name}</p>
    </>
  );
}

export default App;

pタグの欄にポケモンの名前が表示されていますね~~~!!!!!!

UI実装とデータ表示

useSWRのisError,isLoadingを使用してエラーとローディング状態を管理してやります

import "./App.css";
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import type{ SubmitHandler } from "react-hook-form";
import { useState } from "react";
import useFormWithValidation from "./useFormWithValidation";
import { useFetchPoke } from "./useFetchPoke";

type formInputs = {
    pokeId: string;
};

function App() {
  const [pokeId, setPokeId] = useState<string>("");
  const form = useFormWithValidation();

  const { poke, isError, isLoading } = useFetchPoke(pokeId);

  const onSubmit: SubmitHandler<formInputs> = (data) => {
    setPokeId(data.pokeId);
  };

  if (isError) return <div>エラーが発生しました</div>;

  if (isLoading) return <div>ローディング中...</div>;
  
  
  return (
    <>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
          <FormField
            control={form.control}
            name="pokeId"
            render={({ field }) => (
              <FormItem>
                <FormLabel>ポケモンのID</FormLabel>
                <FormControl>
                  <Input placeholder="ポケモンのIDを入力してください" {...field} />
                </FormControl>
                <FormDescription>
                  ポケモンのIDを入力してください
                </FormDescription>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit">送信</Button>
        </form>
      </Form>
      <p>{poke?.name}</p>
    </>
  );
}

export default App;

簡単ですね〜〜〜〜〜!

useSWRを使用するとキャッシュが効くのでパフォーマンスが良いらしいです!!!検証はしていません!

最後に作成したApp.tsxです

import "./App.css";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { SubmitHandler } from "react-hook-form";
import { useState } from "react";
import useFormWithValidation from "./useFormWithValidation";
import { useFetchPoke } from "./useFetchPoke";

type formInputs = {
  pokeId: string;
};

function App() {
  const [pokeId, setPokeId] = useState<string>("");
  const form = useFormWithValidation();

  const { poke, isError, isLoading } = useFetchPoke(pokeId);

  const onSubmit: SubmitHandler<formInputs> = (data) => {
    setPokeId(data.pokeId);
  };

  if (isError) return <div>エラーが発生しました</div>;

  if (isLoading) return <div>ローディング中...</div>;
  return (
    <>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
          <FormField
            control={form.control}
            name="pokeId"
            render={({ field }) => (
              <FormItem>
                <FormLabel>ポケモンのID</FormLabel>
                <FormControl>
                  <Input
                    placeholder="ポケモンのIDを入力してください"
                    {...field}
                  />
                </FormControl>
                <FormDescription>
                  ポケモンのIDを入力してください
                </FormDescription>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit">送信</Button>
        </form>
      </Form>
      {poke ? (
        <div className="outline-solid flex flex-col items-center">
          <div className="flex">
            <p className="text-2xl font-bold m-2">ID:{poke?.id}</p>
            <p className="text-2xl font-bold m-2">{poke?.name}</p>
          </div>

          <img src={poke?.image} alt={poke?.name} />
          <p>タイプ:{poke?.type}</p>
          <div className="flex flex-col">
            <p>高さ:{poke?.height}</p>
            <p>重さ:{poke?.weight}</p>
          </div>
          <p>~特性~</p>
          {poke?.abilities.map((ability, index) => (
            <span key={index}>
              {ability}
              <br />
            </span>
          ))}
        </div>
      ) : (
        <p>検索しよう!</p>
      )}
    </>
  );
}

export default App;

終わりに

ここまで読んでいただき感謝します.

色々課題は残りますね.
useCallback使った方いいんじゃ?とか
idをstringじゃなくnumberにした方がいいのでは?とか

今後も学び続けますよ

それにしてもshadcnのおかげで綺麗なUIになりますよね

CSS苦手な私にとって嬉しい存在です.

Discussion