📝

Next.jsを使ってモーダルで入力フォームを作る

2023/05/01に公開

ここで話すこと

Next.jsでモーダルを作ろうとして色々ごちゃついたので、将来の自分のためにメモします。

構成

  • Next.js 13.3.0
  • Tailwind 3.3.1

参考にさせていただいた記事

https://imatomix.com/imatomix/notes/1591689628000

最終的な着地点

最終的には、一つのモーダルコンポーネントを使いまわして、複数のデータ入力を捌く形になります。

このようにボタンを押したらモーダルが開き、モーダル入力フォームが出現します。

概要

まず、index.js(親コンポーネント)とModal.js(子コンポーネント)、Panel.js(孫コンポーネント)といった構成になります。

index.jsがしていること

index.jsでは

  • モーダルの状態管理
  • 入力した値を保存する処理
  • ボタンの色を変える処理

などをしています。
やっていることとしては、割と全体的なことで一番ボリューミーですね。

index.js
import Header from './components/Header';
import Button from "./components/Button";
import { useState } from "react";
import Modal from "./components/Modal";
import Panel from "./components/Panel";
import { useRouter } from "next/router";

export default function Home() {

  const defaultButtonStyle = "px-8 md:py-4 my-10 md: mx-12 rounded border-2 border-black font-bold bg-white text-black  shadow-md transition-all duration-1000 ease-out hover:shadow-none disabled:cursor-default disabled:opacity-50 text-xl md:text-2xl";
  const changeButtonStyle = "px-8 md:py-4  my-10 md: mx-12 rounded border-2 border-black font-bold text-black bg-cyan-200 shadow-md transition-all duration-1000 ease-out hover:shadow-none disabled:cursor-default disabled:opacity-50 text-xl md:text-2xl";
  // 入力フォームの状態
  const router = useRouter();
  const[allValue, setAllValue] = useState({
    morning: "",
    noon: "",
    evening: ""
  });
  
  // 
  const[addClassed , setAddClassed] = useState({
    morning: defaultButtonStyle,
    noon: defaultButtonStyle,
    evening: defaultButtonStyle,  
  });
  
  // モーダルの状態
  const [isOpenModal, setIsOpenModal] = useState({
    state:false,
    kind:null,
    takeValue:null
  });
  // 入力フォーム保存
  const saveValue = (time, tex) => {
    setAllValue({...allValue, [time]:tex});

    if(tex===""){
      console.log("nochange");
      setAddClassed({
        ...addClassed, [time]:defaultButtonStyle
      });
    }else{
      console.log("change");
      setAddClassed({
        ...addClassed, [time]:changeButtonStyle
      });
    };

    console.log("セーブデータ朝"+allValue.morning);
    console.log("セーブデータ昼"+allValue.noon);
    console.log("セーブデータ夜"+allValue.evening);
  }
    
    
    
    // モーダルの開閉処理
  const toggleModal = (e,i) => {
    let idx = i;
    if (e.target === e.currentTarget) {
      switch(idx){
        case "morning":
          idx = i;
          console.log(idx);
          break;
        case "noon":
          idx = i;
          console.log(idx);
          break;
        case "evening":
          idx = i;
          console.log(idx);
          break;
      }
      setIsOpenModal({...isOpenModal, state:!isOpenModal.state, kind: idx, takeValue: allValue[idx]}); 
    }
  };
  const[tf , setTf]=useState(true);
  const [color , setColor]=useState("bg-white");
  
  const lockman = () => {
    if(allValue.morning!=="" && allValue.noon!=="" && allValue.evening!==""){
      setTf(false)
      setColor("bg-cyan-200");
    }else{
      setTf(true)
      setColor("bg-white");
    }
  }
  
  const hand_over =()=>{
    router.push({
      pathname:"/result",
      query: {"prompt":allValue.morning + "," + allValue.noon + "," + allValue.evening }
    })
    console.log("zakoga");
  }

  return (
    <>
      <main>  
        <div className='flex flex-col m-0 h-screen'>
          <Header />
          <div className='flex justify-center px-xl'>
            <div className='container px-4'>
              <p className='text-center mb-20 mt-32 text-xl text-black md:text-5xl font-bold '>AIちゃんの絵日記</p>
              <p className='text-center my-10 text-xl md:text-3xl text-black font-bold'>今日あった出来事を朝昼晩に分けて<br/>簡単に入力するとAIちゃんが絵日記にしてくれます!!</p>
              <div className='mx-auto my-10 flex justify-center'>
                <Button type="button" onClick={() => toggleModal(true,"morning")} className={addClassed.morning}></Button>
                <Button type="button" onClick={() => toggleModal(true,"noon")} className={addClassed.noon}></Button>
                <Button type="button" onClick={() => toggleModal(true,"evening")} className={addClassed.evening}></Button>
                {/* 参考演算子でisOpenModal.stateが真なら表示 */}
                {isOpenModal.state && (
                  <Modal close={toggleModal} saveValue={saveValue} lockman={lockman} viewValue={isOpenModal.kind} takeValue={allValue[isOpenModal.kind]}>
                  </Modal>
                )}
              </div>
              <div className='flex justify-center '>
                <Button disabled={tf} onClick={() => hand_over()} className={`disabled:opacity-100 px-12 md:py-4  md: mx-12 rounded border-2 border-black font-bold ${color} text-black  shadow-md transition-all duration-1000 ease-out hover:shadow-none disabled:cursor-default  text-xl md:text-2xl`}>
                  作成
                </Button>
              </div>
            </div>
          </div>
        </div>
      </main>
    </>
  )
}
s

Modalコンポーネントのしていること

Modalコンポーネントでは

  • パネルの外をクリックすると閉じる
  • Panel.js(孫コンポーネント)にPropsを渡す

といった役割を持っています。メインはパネルの外をクリックしたときに必要な処理ですね。

index.js
import { useState } from "react";
import React from "react";
import Panel from "./Panel";

const Modal = props => {
  const [isMouseDown, setIsMouseDown] = useState(false);

  const onMouseDown = e => {
    if (e.target === e.currentTarget) {
      setIsMouseDown(true);
    }
  };

  const onMouseUp = e => {
    if (isMouseDown) {
      props.close(e);
    }
    setIsMouseDown(false);
  };

  return (
      <div
        className="fixed flex justify-center items-center top-0 left-0 right-0 z-50 w-full p-4 overflow-x-hidden overflow-y-auto h-[calc(100%-1rem)] max-h-full"
        onMouseDown={onMouseDown}
        onMouseUp={onMouseUp}
      >
        <Panel close={props.close} saveValue={props.saveValue} viewValue={props.viewValue} takeValue={props.takeValue} lockman={props.lockman}/>
      </div>
  );
};

export default Modal;

Panelコンポーネントのしていること

Panelコンポーネントでは

  • モーダルのHTML
  • handleChange(入力フォームの値が変わる毎)の処理
  • 送信ボタンを押したときの処理
index.js
import React from "react";
import { useState } from "react";


const Panel = props => {
  const [formValue, setFormValue] = useState({
    idx: null,
    value: null
  });

  // 入力フォームの値が変わった時の処理
  const handleChange = e => {
    console.log(props.viewValue);
    console.log(e.target.value);
    console.log(e.target.name);
    const value = e.target.value;
    const name = e.target.name;
    setFormValue({ ...formValue, idx: e.target.name, value: e.target.value});
    console.log(formValue.value);
    props.saveValue(e.target.name, e.target.value);
  }
  
  // 送信を押したときの処理
  const submit = e => {
    props.saveValue(formValue.idx, formValue.value);
    props.lockman();
    e.preventDefault();
    if (props.close) {
      props.close(e);
    }
  };

  return (
    <>
      <div className="relative w-full mx-0 max-w-2xl">
        <div className="relative bg-white rounded-lg shadow dark:bg-gray-700">
          <form>
            <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600">
                <h3 className="text-xl font-semibold text-gray-900 dark:text-white">
                    できごとをかいてね
                </h3>
            </div>
            <div className="p-6 space-y-6">
              <textarea type="text" name={props.viewValue} id={props.viewValue} onKeyUp={handleChange} placeholder="やったこと..." maxlength="150" className=" text-slate-400 text-xl p-2.5 dark:bg-gray-700 border rounded-sm w-full dark:border-gray-500 leading-5 h-56">
                {props.takeValue}
              </textarea>
            </div>
            <div className="flex justify-end items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
                <button onClick={props.close} type="button" className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
                  キャンセル
                </button> 
                <button onClick={submit} type="submit" className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
                  決定
                </button>
            </div>
          </form>
        </div>
      </div>
    </>
  );
};

export default Panel;

躓いたところ

躓いたところとしては、textarea要素のhandleChangeの挙動です。

<div className="p-6 space-y-6">
	<textarea type="text" onKeyUp={handleChange} placeholder="やったこと..." maxlength="150" name={props.viewValue} id={props.viewValue} >
		{props.takeValue}
	</textarea>
</div>

onChangeだと最初の一文字や最後の一文字に入力した値が反映されないので、onKeyUpを使用したのですが、こいつだと今度は予測変換を使ったときに反映されないという問題が起こりました。あと当然だけど、モーダルに入力フォームぶっこんだらstate管理めんどくせぇ。
これは困った。。。

解決策

解決策としては、React Hook Formを導入するということです。
ReactHookFormとは、公式サイトによれば「シンプルかつ、拡張性のある、使い勝手の良いフォームバリデーションライブラリ」という説明がされています。
なんかstate管理がよりシンプルになったり、レンダリング回数を減らせたり、バリデーションが簡単に導入出来たりと色々おいしそうです。

問題のhandleChangeですが、こちらもどうやらReactHookFormを使うことで解決できそうです。
詳しく知りたい方は、以下の記事を参考にしてください。
https://weseek.co.jp/tech/1238/

実装

まぁとりあえず、考えるより先にnpm installですよ。

$ npm install react-hook-form

インストールが完了したら、見様見真似で自分のコードに対応させていきます。

Panel.js
import React from "react";
- import { useState } from "react";
+ import { useForm } from 'react-hook-form';


const Panel = props => {
-  const [formValue, setFormValue] = useState({
-    idx: null,
-    value: null
-  });
-  // 入力フォームの値が変わった時の処理
-  const handleChange = e => {
-    console.log(props.viewValue);
-    console.log(e.target.value);
-    console.log(e.target.name);
-    const value = e.target.value;
-    const name = e.target.name;
-    setFormValue({ ...formValue, idx: e.target.name, value: e.target.value});
-    console.log(formValue.value);
-    props.saveValue(e.target.name, e.target.value);
-  }
-  // 送信を押したときの処理
-  const submit = e => {
-    props.saveValue(formValue.idx, formValue.value);
-    props.lockman();
-    e.preventDefault();
-    if (props.close) {
-      props.close(e);
-    }
-  };
  
+  const { register, handleSubmit } = useForm();
+  // 送信を押したときの処理
+  const onSubmit = (data) => {
+    console.log(data[props.viewValue]);
+    props.saveValue(props.viewValue,data[props.viewValue]);
+    if (props.close) {
+      props.close(false, props.viewValue);
+    }
+  };

  return (
    <>
      <div className="relative w-full mx-0 max-w-2xl">
        <div className="relative bg-white rounded-lg shadow dark:bg-gray-700">
-          <form>
+          <form onSubmit={handleSubmit(onSubmit)}>
            <div className="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600">
                <h3 className="text-xl font-semibold text-gray-900 dark:text-white">
                    できごとをかいてね
                </h3>
            </div>
            <div className="p-6 space-y-6">
-              <textarea type="text" name={props.viewValue} id={props.viewValue} onKeyUp={handleChange} placeholder="やったこと..." maxlength="150" className=" text-slate-400 text-xl p-2.5 dark:bg-gray-700 border rounded-sm w-full dark:border-gray-500 leading-5 h-56">
+              <textarea type="text" {...register(props.viewValue)} name={props.viewValue} id={props.viewValue}  placeholder="やったこと..." maxlength="150" className=" text-slate-400 text-xl p-2.5 dark:bg-gray-700 border rounded-sm w-full dark:border-gray-500 leading-5 h-56">
                {props.takeValue}
              </textarea>
            </div>
            <div className="flex justify-end items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
                <button onClick={props.close} type="button" className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600">
                  キャンセル
                </button> 
-                <button onClick={submit} type="submit" className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">

+                <button type="submit" className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
                  決定
                </button>
            </div>
          </form>
        </div>
      </div>
    </>
  );
};

export default Panel;

あぁ、、、なんと恐ろしいこと。。。
logを除いて15行近くあったstate管理や送信処理が8行になりました。。。

Panelコンポーネント以外にindex.jsやModalコンポーネントでも、propsの受け渡しなどで見直しできるところがあり、最適化できました。そこら辺の説明は省きます。

まとめ

状態管理ライブラリを使えば綺麗にまとまりそうですけど、このくらいの規模だったらまだpropsで渡してもいいかなと思いました。ここら辺の状態管理ライブラリ使う使わないの基準ってなんでしょうかね。気になります。

Discussion