0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

APIを使って、フォームの操作を反映させるアプリ ft. React Hook Form + Next.js

Last updated at Posted at 2023-04-22

練習でWebアプリを作ったので、ポートフォリオとしても使えるようにこちらにも仕組みをメモしておきます。

【作ったもの】
機能をいろいろと試すためにつくったものなので、あまり実用的ではありません。デモみたいなものです。
インプットフォームに文字を入力して送信すれば、アプリに変更が保存され、ロードボタンを押せばフォームに入力した中身が一覧で返ってきます。一覧の中で、削除ボタンもあります。
URL:https://api-with-reacthookform.vercel.app/
ちゃんとしたデータベースがあるわけではないので、入力された情報をずっと保持できるわけではありません。

【開発環境】

node:v18.15.0
npm:v9.6.4
tailwindcss:v3.3.1
//cssを当てるライブラリにはtailwindを用いました。

【フォルダツリー】

pages
  index.tsx
  _app.tsx
  _document.tsx
└─api
   serverAPI.tsx
   [formId].tsx
//regardingpagesはpagesと同階層。
regardingpages
├─components
      handleAPI.tsx
      Input.tsx
├─data
      Forms.tsx
└─types
        FormsType.tsx
        FormType.tsx

【開発概要】
①フォームコンポーネント。
react-fook-formでinputフォームを作成しました。表示側とロジック側に切り分けて、使い回しができるようにロジックを関数コンポーネントとしてdefaultでexportしております。

② GET,POST,DELETE等のメソッドを定義した非同期通信の設定。
fetchの第二引数はデフォルトではGETメソッドだけが定義されています。もし、他にもメソッドを定義したいのであれば、その都度第二引数に値をセットしなければなりません。

③GET,POSTのリクエストに対するレスポンスの定義。DELETEのリクエストに対するレスポンスの定義(動的APIルーティングの利用)。

④非同期通信の各メソッドをフロント側で呼び出し関数でラッピングして、ボタンをクリックしたタイミングで各関数(内部的にはメソッド)が発火するように整える。

言葉だけで説明するのは難しいものがありますね。
以下、実際のコードを表示して、逐次説明を入れていこうと思います。

【コードの様子】
Ⅰ:最初に、定義した型を紹介しようと思います。

//FormType.tsx   
export type FormType={
    userName?:string;
    password?:string;
};

フォームに入力された値に関する型です。ここでは、ユーザーの名前とパスワードが入力されることを想定しているので、どちらもstring型になります。

//FormsType.tsx
import { FormType } from "./FormType"

export type FormsType={
    id:number
    form:FormType,
}

API通信でform(ここではuseNameとpasswordに入力された値を合わせてformとよんでいます)を取りまわすときidが振られてある方がわかりやすいので、formにidを与えたformsを使ってAPIの通信を行います。
実際、formsは以下のようになっています。

//Forms.tsx
import { FormsType } from "../types/FormsType";

export const forms:FormsType[]=[
    {
        id:1,
        form:{
            userName:"Joe Biden",
            password:"the University of Delaware"
        }
    },
    {
        id:2,
        form:{
            userName:"Donald Trump",
            password:"the University of Pennsylvania"
        }
    }
];

このformsの配列にPOSTメソッドで送られてきたbodyをpushしたり、DELETEメソッドで指定されたidのformsを消したりするわけです。

Ⅱ:フロント・Inputの表示側。

//index.tsx
import { useState } from "react";
import Input from "../regardingpages/components/Input";
import {useForm} from "react-hook-form"
import { FormType} from "../regardingpages/types/FormType";

export default function Home() {
  const {control,handleSubmit,reset}=useForm<FormType>({defaultValues:{userName:"",password:""}})
  
  const onSubmit=(dataSet:FormType)=>{
    console.log(dataSet);
    reset();   
  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24 text-center">
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label className="cursor-pointer" htmlFor="userName">userName:</label>
          <Input className="p-2 m-1" id="userName" name="userName" control={control} placeholder="name" />
        </div>
        <div>
          <label className="cursor-pointer" htmlFor="password">Password:</label>
          <Input id="password" name="password" control={control} placeholder="password" className="p-2 m-1" type="password"/>
        </div>
        <button type="submit" className="rounded border-black bg-slate-500 p-1 m-1">Submit</button>
      </form>
    </main>
  )
}

useFormというreact-hook-formのカスタムフックからcontrol,handleSubmit,resetの各メソッドを取り出します。
controlはコンポーネントをreact-hook-formに登録するためのメソッドです。(Inputコンポーネント自体はinput要素でも何でもないので、このコンポーネントとロジック側でのinput要素とを紐づけるためにcontrolを渡す必要があります)
resetはその名の通り、フォームに入力された値を消すメソッドです。valueのうち、どのプロパティの値を消すかを選択することもできます。
handleSubmitは第一引数に成功時の処理を、第二引数に失敗時の処理を記述することができます。

※公式ドキュメントURL(https://react-hook-form.com/api/useform/)
あとは、InputコンポーネントにnameやらplaceholderやらclassNameを流し込んであげれば大丈夫です。

Ⅲ:Inputコンポーネント

//Input.tsx
import { InputHTMLAttributes } from "react"
import { FieldValues, UseControllerProps, useController } from "react-hook-form"
import { FormType } from "../types/FormType";

type InputProps<T>=FormType & InputHTMLAttributes<HTMLInputElement> & UseControllerProps;

const Input=<T extends FieldValues>(props:InputProps<T>):JSX.Element=>{
    const {name,control,...inputAttributes}=props;
    const {
        field,
        fieldState:{error},
    }=useController({name,control,rules:{required:{value:true,message:"Please Enter words"}}})

    return(
        <>
            <input  id={name}  {...inputAttributes} {...field} />
            {error && (
                <div>
                    <span>{error.message}</span>
                </div>
            )}
        </>
    )
};

export default Input

nameやcontrolをpropsとして受け取ってuseControllerの引数に設定します。nameは絶対に必要だそうです。FormProviderを使う時、controlは任意だそうですが、基本的には表示側のコンポーネントとinput要素を紐づけるのに必要なので、おまじない的にポンポン入れて大丈夫だと思います。
rulesはvalidationの条件で、ここでは「入力必須」を表現しています。messageを設定しておかないと、errorのmessageプロパティが空のままになってしまうので注意です。
公式ドキュメントURL(https://react-hook-form.com/api/usecontroller/)

〇defaultValuesを設定すべき理由。
さらっと流していましたが、useFormの中にdefaultValuesを設定していました。これには二つほど理由があります。
1.初期値ではunKownになっており、undefinedはdefaultValue(ロジックサイド。useControllerのなか)にたいしても、defaultValues(表示サイド。useFormのなか)にたいしても適用することができません。即ち、不定義は初期値として扱えないのです。このとき、field-levelでdefaultValueを設定するかuseFormでdefaultValuesを設定するかを選べます。
2.resetメソッドを用いたいとき、useFormの段階でdefaultValuesを設定しておかねばなりません。

1の理屈はおそらく、Reactの仕様によるものであろうと思われます。
ReactのFormコンポーネント関連の考え方にはcontrolledとuncontrolledの二つがあるそうで、平たく言えばcontrolledはstateを用いた変更を行うものだそうです。注意しないといけないのは、stateで管理されていないにもかかわらずstateを用いて管理しようとしたとき(あるいは、undefinedが初期値として入っていたり、undefinedをsetStateの引数にしようとしたとき)、エラーが吐き出されるということです。
自分に吐かれたエラー文はこのようなものでした。
"component is changing an uncontrolled input to be controlled"
フォームをcontrolledされたものとして使いたい(stateで管理したい)のであれば、初期値は定めておかなければならず、その仕様上react-hook-formでもuseFormの初期値を定めておかなければならないということである……とわたしは解釈しました。

参考サイトURL
「React: フックで値を定めた要素に制御されたコンポーネントを使うよう警告が出る」URL(https://qiita.com/FumioNonaka/items/0b4771fdce748e0d67ce)
「Reactのuncontrolled input warningで困った時に確認するべきたった1つのこと」URL(https://blog.mitsuruog.info/2017/09/react-uncontrolled-input)

・tips
filedのうち、refを取り出すことがありますが、(field:{ref,...inputProps}など)これはMaterialUIのTextFieldを用いるときrefがinputRefと属性名が微妙に変わる事態に対処するときに現れる表現だったりします。TextFieldにたいしては、fieldをスプレッド構文で展開して流し込もうとするとrefが正しく受け渡されないためです。

Ⅳ:APIのGET,POSTメソッドの定義

//hanldeAPI.tsx
import { FormType } from "../types/FormType";

export const path="/api/serverAPI/"

export const fetchForms=async()=>{
    const res=await fetch(path,{
        method:"GET",
        headers:{
            "Content-Type":"application/json"
        }
    })
    const detail=await res.json();
    return detail
};

export const submitForm=async(dataSet:FormType)=>{
    const res=await fetch(path,{
        method:"POST",
        body:JSON.stringify({form:dataSet}),
        headers:{
            "Content-Type":"application/json"
        }
    })
    const detail=await res.json();
    return detail
}

ここで、index.tsxにおいても、responseを取りまわせるようにコードを増強します。

//index.tsx
import { useState } from "react";
import Input from "../regardingpages/components/Input";
import {useForm} from "react-hook-form"
import { FormType} from "../regardingpages/types/FormType";
import { FormsType} from "../regardingpages/types/FormsType";
import { deleteForm, fetchForms, submitForm } from "../regardingpages/components/handleAPI";
import Link from "next/link";

export default function Home() {
+  const [data,setData]=useState<FormsType[]>([]);
  const {control,handleSubmit,reset}=useForm<FormType>({defaultValues:{userName:"",password:""}})

+  const getForms=async()=>{
+    const detail=await fetchForms();
+    setData(detail);
+  }
  
 const onSubmit=async(dataSet:FormType)=>{
+    const detail=await submitForm(dataSet);
+    console.log(detail);
    reset();   
  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24 text-center">
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label className="cursor-pointer" htmlFor="userName">userName:</label>
          <Input className="p-2 m-1" id="userName" name="userName" control={control} placeholder="name" />
        </div>
        <div>
          <label className="cursor-pointer" htmlFor="password">Password:</label>
          <Input id="password" name="password" control={control} placeholder="password" className="p-2 m-1" type="password"/>
        </div>
        <button type="submit" className="rounded border-black bg-slate-500 p-1 m-1">Submit</button>
      </form>
+     <div>
+          <button onClick={getForms} className="rounded border-black bg-slate-500 p-1 my-1">Load Forms</button>
+      </div>
+      <ul className="block w-full">
+            {
+                data.map(d=>(
+                    <li key={d.id} className="bg-blue-300 w-9/12 my-2 mx-auto ">
+                        {d.form.userName}:{d.form.password}:{d.id}
+                    </li>
+                ))
+            }
+            </ul>
    </main>
  )
}

GETメソッドはただただfetchの第二引数のmethodプロパティに明示しただけなので、あまり言うこともありません。強いて言うなら、第二引数が丸々なくても動くはずだということくらいです。

POSTメソッドは少々注意が必要です。
index.tsxの側で、onSubmit関数の中でsubmitForm関数を呼び出してその引数にform(useNameとpasswordのオブジェクト)を渡し、submitForm関数の中で受け取ったpropsをjson形式になおし、bodyとして送信する手順を踏んでおります。そしてそれを、apiフォルダ配下のserverAPIで受け取って、formsの配列にidをつけてpushすることで配列を更新しています。

//serverAPI.tsx
import type { NextApiRequest, NextApiResponse } from 'next'
import { FormsType } from '../../regardingpages/types/FormsType'
import { forms } from '../../regardingpages/data/Forms'


export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<FormsType | FormsType[]>
) {
  if(req.method==="GET"){
    res.status(200).json(forms);
  }else if (req.method==="POST"){
    const form=req.body.form ;
    const newForm={
      id:forms.length+1,
      form:form
    };
    forms.push(newForm);
    res.status(201).json(newForm);
  }
}

Ⅴ:DELETEメソッドの定義。
DELETEメソッドは動的APIルーティングを用いております。mapメソッドの中でボタンのonClickイベントとしてelminate関数を仕込み、deleteFormに対してクリックされたformのidを渡しております。index.tsxの付加コードのところとhandleAPIの付加コードの部分を一緒にご覧ください。

//index.tsx
//・・・import文は省略
export default function Home() {
  const [data,setData]=useState<FormsType[]>([]);
  const {control,handleSubmit,reset}=useForm<FormType>({defaultValues:{userName:"",password:""}})

  const getForms=async()=>{
    //・・・省略
  }
  
  const onSubmit=async(dataSet:FormType)=>{
    //・・・省略   
  };

+  const eliminate=async(formId:number)=>{
+    const detail=await deleteForm(formId);
+    console.log(detail);
+    getForms();
+  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24 text-center">
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label className="cursor-pointer" htmlFor="userName">userName:</label>
          <Input className="p-2 m-1" id="userName" name="userName" control={control} placeholder="name" />
        </div>
        <div>
          <label className="cursor-pointer" htmlFor="password">Password:</label>
          <Input id="password" name="password" control={control} placeholder="password" className="p-2 m-1" type="password"/>
        </div>
        <button type="submit" className="rounded border-black bg-slate-500 p-1 m-1">Submit</button>
      </form>
      <div>
          <button onClick={getForms} className="rounded border-black bg-slate-500 p-1 my-1">Load Forms</button>
      </div>
      <ul className="block w-full">
            {
                data.map(d=>(
                    <li key={d.id} className="bg-blue-300 w-9/12 my-2 mx-auto ">
                        {d.form.userName}:{d.form.password}:{d.id}
+                       <div className="flex justify-evenly">
+                           <button onClick={()=>eliminate(d.id) } className="rounded border-black bg-red-400 p-1 mx-1">Delete</button>
+                            <Link href={`/api/${d.id}`}><button className="rounded border-black bg-green-400 p-1 mx-1">GO TO API!</button></Link>
+                        </div>
                    </li>
                ))
            }
            </ul>
    </main>
  )
}
//handleAPI.tsx
import { FormType } from "../types/FormType";

export const path="/api/serverAPI/"

export const fetchForms=async()=>{
    //・・・省略
};

export const submitForm=async(dataSet:FormType)=>{
    //・・・省略
}

+export const deleteForm=async(formId:number)=>{
+    const res=await fetch(`/api/${formId}`,{
+        method:"DELETE",
+        headers:{
+            "Content-Type":"application/json"
+        }
+    })
+    const detail=await res.json();
+    return detail
+};

動的APIルーティングにおいては、ローカルサーバーの場合なら"localhost3000/api/1"などのクエリになり、その数字の箇所を用いてformsのidと一致するものにアクセスするというイメージになります。

//formId.tsx
import { NextApiRequest, NextApiResponse } from "next";
import { forms } from "../../regardingpages/data/Forms";
import { FormsType } from "../../regardingpages/types/FormsType";

export default function handler(req:NextApiRequest,res:NextApiResponse<FormsType>){
    const formId=req.query.formId
    if(typeof formId !=="string") return ;
    if(req.method==="GET"){
        const form=forms.find(form=>form.id===parseInt(formId))
        form && res.status(200).json(form);
    }else if(req.method==="DELETE"){
        const deletedForms= forms.find(form=>form.id===parseInt(formId))
        const index=forms.findIndex(form=>form.id===parseInt(formId))
        forms.splice(index,1)
        deletedForms && res.status(200).json(deletedForms);
    }
};

クエリなので文字列になるはずですが、そうはならなかった場合はそこで処理を終えるようにしています。また、formやdeletedFormがundefinedになるような場合も、特に返すべきレスポンスは定義していません。
この点で、エラーハンドリングがなっていない(けしからん)APIになるわけですが、いかんせん学習途上なので、そこまでしっかりしたものは作れませんでした。

・tips
GETメソッドにたいするレスポンスも定義されてあるので、動的ルーティングを用いて直に"localhost:3000/api/1"などにアクセスしようとしても、成功します。formsのidが1のオブジェクトにアクセスできます。今回の例で言えば id:1,form:{userName:"Joe Biden",password:"the University of Delaware"}というdataがお目に係れます。

Ⅵ:そのほか機能の追加、名前の修正

//・・・import文

+export async function getStaticProps(){
+  const res=fetch("https://api-with-reacthookform.vercel.app/api/serverAPI").then(data=>data.json()).catch(error=>{throw error});
+  //もし不具合が生じたらerrorを吐き出させることで、処理を終了させる。
+  try{
+    const forms=await res;
+    return{
+      props:{
+        forms
+      }
+    }
+  }catch{
+    const forms=[{
+      id:0,
+      form:{
+        userName:"Amagi Yuri",
+        password:"the University of Kyushu-u",
+      }
+    }]
+    return{
+      props:{
+        forms
+      }
+    }
+  }
+}

export default function Home({forms}:any) {
  const [data,setData]=useState<FormsType[]>(forms);
+  const [loaded,setLoaded]=useState<boolean>(false);
  const {control,handleSubmit,reset}=useForm<FormType>({defaultValues:{userName:"",password:""}})

  const display=async()=>{
    //getFormsからdisplayに名前の修正
  }
  
  const onSubmit=async(dataSet:FormType)=>{
    const detail=await submitForm(dataSet);
 +   loaded && display();
    console.log(detail);
    reset();   
  };

  const eliminate=async(formId:number)=>{
    const detail=await deleteForm(formId);
    console.log(detail);
    display();
  };

  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24 text-center">
 +     <button onClick={()=>console.log(forms)} className="rounded border-black bg-slate-500 py-1 px-2 m-2">Confirm SSG</button>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label className="cursor-pointer" htmlFor="userName">userName:</label>
          <Input className="p-2 m-1" id="userName" name="userName" control={control} placeholder="name" />
        </div>
        <div>
          <label className="cursor-pointer" htmlFor="password">Password:</label>
          <Input id="password" name="password" control={control} placeholder="password" className="p-2 m-1" type="password"/>
        </div>
        <button type="submit" className="rounded border-black bg-slate-500 p-1 m-1">Submit</button>
      </form>
      <div>
          <button onClick={()=>{setLoaded(true),display()}}  className="rounded border-black bg-slate-500 py-1 px-2 m-1">Load Forms</button>
    +      <button onClick={()=>setLoaded(false)}  className="rounded border-black bg-slate-500 py-1 px-2 m-1">Close Forms</button>
      </div>
      <ul className="block w-full">
            {
+              loaded &&
                data.map(d=>(
                    <li key={d.id} className="bg-blue-300 w-9/12 my-2 mx-auto ">
                        {d.form.userName}:{d.form.password}:{d.id}
                        <div className="flex justify-evenly">
                            <button onClick={()=>eliminate(d.id) } className="rounded border-black bg-red-400 p-1 mx-1">Delete</button>
                            <Link href={`/api/${d.id}`}><button className="rounded border-black bg-green-400 p-1 mx-1">GO TO API!</button></Link>
                        </div>
                    </li>
                ))
            }
            </ul>
    </main>
  )
}

急遽後付けでコードを更新しものがⅥであります。(編集更新日:2023/4/24)
機能としてはloadedというbooleanのstateを用いて、formsを画面に表示するかどうかを管理するというものと、getStaticPropsを用いてプレレンダリングでformsの初期値を設定するコードを付け加えました。getStaticPropsなどは、静的ページを作るからこそSEO対策として強みを発揮するわけですが、ここでは const [data,setData]=useState<FormsType[]>(forms);のuseStateの引数に初期値として設定される役割を負うのみとなっています。
どのみちloadedがtrueにならないといけない(Load Formsボタンが押されないといけない)ので初期値の表示はしませんが、初めからdataをmapで表示しているたならちゃんとした静的ページになると思われます。

【最後に】
くぅ~疲れましたw

エラーハンドリングの不在といい、型に関する理解の甘さといい、ネーミングの悪さといい、まだまだ粗が目立つ代物ではありますが、一応は作れてよかったと思います。
ハンズオンで学習して理解したものを自分なりに組み合わせつつ再構築し、アウトプットする過程で理解が甘いところを調べて詰めていく。できることならコードや公式ドキュメントを読むだけでぱぱっと理解できれば苦はないのでしょうが、まだそこまで理解力がないので、こういう地道な作業を積み重ねようという次第であります。
なにより、コードの意図と機能を把握しよう不断に考えることが大切なんだろうと思いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?