LoginSignup
10
10

More than 5 years have passed since last update.

モダンな環境でHTMLフォームを作ってみたらReact Hooksのパワーを思い知った件

Last updated at Posted at 2019-04-01

はじめに

varとかfunctionとかいう文字列を見ると蕁麻疹が出るようになった今日このごろ。
IEはご臨終なさったのだ。
Babelさんありがとうの感謝を込めてフォームが簡単に作れることを示してみたい。

環境構築から

  • 最新のnodeを入れたら適当なディレクトリでnpm initしてEnter連打して「yes」。
  • npm i parcel
  • package.jsonを開いてscriptsの項目を変更
package.json
  "scripts": {
    "start": "parcel ./index.html",
    "build": "parcel build ./index.html",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  • 以下のファイルを作る
./index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
</head>
<body>
  <div id="root"></div>
  <script type="text/javascript" src="./src/index.tsx"></script>
</body>
</html>
./src/index.tsx
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import Form from './form'

ReactDOM.render(<Form />, document.getElementById('root'))
./src/form.tsx
import * as React from 'react'
const Form = () => <div><h1>Hello Form</h1></div>
export default Form
  • npm run start

これでlocalhost:1234にアクセスしてHello Formが見られればひとまず成功。
Parcelが裏でReactとかTypeScriptのモジュールをダウンロードしてくれます。
噴き出してしまうほど簡単ですね。
VsCodeでコーディング用の設定も作っておきます。

  • npm i -D @types/react, @types/react-dom, tslint
  • mkdir .vscode
./.vscode/extentions.json
{
  "recommendations": [
    "eg2.tslint",
    "EditorConfig.EditorConfig",
    "esbenp.prettier-vscode"
  ]
}
./.vscode/setting.json
{
  "editor.tabSize": 2,
  "editor.insertSpaces": true,
  "editor.renderWhitespace": "boundary",
  "editor.rulers": [
    120
  ],
  "files.encoding": "utf8",
  "files.trimTrailingWhitespace": true,
  "files.insertFinalNewline": true,
  "search.exclude": {
    "**/public/**": true,
    "**/.cache/**": true
  },
  "[javascript]": {
    "editor.formatOnSave": true
  },
  "[typescript]": {
    "editor.formatOnSave": true
  },
  "[typescriptreact]": {
    "editor.formatOnSave": true
  }
}

VsCodeでこう保存すると

const Form = () => {
  return (<div><h1>Hello Form!</h1>
  </div>
  )
}

こう整形されます。楽ですね。

const Form = () => {
  return (
    <div>
      <h1>Hello Form!</h1>
    </div>
  );
};

これで現代人のjavascript環境が最低限整ったので作っていきます。

  • npm run start

VsCodeでソースを変えて保存するとブラウザのF5を押さなくても変更が反映されていることがわかるかと思います。
これが地味なようで開発には大きな恩恵なのです。

設計方針

cssをどうするか

cssに銀の弾丸はない。
というのもcssの仕様が超複雑なルールによって適用されるようになっているからだ。
なので構造的にcssを組んでいくには何らかの仕組みが必要なわけだが、
統一的な勢力はなく、みんなが好き勝手に"ぼくがかんがえたさいきょうのCSS環境"を作っているからだ。
全部一人でやる場合の考え方としては実装とデザインの融合が望ましいというのがある。
というのもReactによるコンポーネント化によってcssが外部に汚染することがなくなったからだ。
BEMとかPostCSSとは何だったのかという状態。
ならスコープを狭められるのがよい。
このhtmlタグのデザインはどこいったかな~と探し回るようではいけない。
理想は近接かインラインで書くことを目標としましょう。
分業なら逆にファイルが分かれていた方がやりやすいかもね。
ここではReactとも親和性が高いemotionを使ってみるよ。

  • npm i emotion, @emotion/core, @emotion/styled

リセッターとしてはress.cssを使う。
ローカルにダウンロードしてきてindex.htmlのheadで読み込んでおこう。

<link rel="stylesheet" type="text/css" href="ress.min.css">

フォームの雛形を作る

./src/form.tsx
import * as React from "react";
import { useState } from "react";

const Form = () => {
  const [radio, setRadio] = useState(true);
  const [text1, setText1] = useState("");
  const [text2, setText2] = useState("");
  console.log(
    `radio: ${radio ? "Old" : "Modan"}, textBox1: ${text1}, textBox2: ${text2}`
  );
  return (
    <form>
      <input type="radio" onChange={() => setRadio(true)} checked={radio} />
      <span>Old Style</span>
      <input type="radio" onChange={() => setRadio(false)} checked={!radio} />
      <span>Modan Style</span>
      <input
        type="text"
        onChange={(e: any) => setText1(e.target.value)}
        value={text1}
      />
      <input
        type="text"
        onChange={(e: any) => setText2(e.target.value)}
        value={text2}
      />
      <button>送信</button>
    </form>
  );
};
export default Form;

スタイルを廃した要素だけのフォームができました。
2つのテキストボックスとラジオボタンの状態がconsole.logで取れているはず。
React Hooksの力ってすげー。

ボタンコンポーネントの分離

  • ボタンとは押した時に関数を実行する要素である。
  • 常にボタンが押せるのではなく無効化状態を用意したい。
  • ボタンが有効なときはホバーでのインタラクションでユーザーに伝えたい。

ボタンを上記のように定義して、そのエッセンスだけを切り出して実装するとこうなる。

./src/button.tsx
/** @jsx jsx */
import { jsx } from "@emotion/core";

interface Props {
  text: string; // 表示されるテキスト
  isButtonOn: boolean; // ボタンのオンオフの状態
  enable: boolean; // trueならクリックしても関数を実行しない
  onClick: any;
}
const ButtonOnStyle = {
  button: {
    color: "#021a3f",
    background: "lightblue"
  }
};
const ButtonOffStyle = {
  button: {
    color: "#052d0c"
  }
};
const ButtonEnableStyle = {
  button: {
    "&:hover": {
      background: "lightgreen"
    }
  }
};
const ButtonDisableStyle = {
  opacity: 0.3
};
const ButtonStyle = {
  button: {
    height: "2rem",
    padding: "0 2rem",
    border: "1px solid #003366"
  }
};
const Button = (props: Props) => (
  <div css={ButtonStyle}>
    <div css={props.isButtonOn ? ButtonOnStyle : ButtonOffStyle}>
      <div css={props.enable ? ButtonEnableStyle : ButtonDisableStyle}>
        {props.enable ? (
          <button onClick={() => props.onClick()}>{props.text}</button>
        ) : (
          <button>{props.text}</button>
        )}
      </div>
    </div>
  </div>
);
export default Button;

どうです?最後のrender部分。
状態に応じてスタイルがどう変わるのかがわかりやすいすっきりとしたcss実装だと思いませんか。
ソース自体もフルconst、pure function、return文onlyで省略されたreturn表記。
音ゲーでいうとフルコンボみたいな状態で、このようなソースがすごく気持ちいいです。

スタイルをつけてみる

HTMLにおけるCSSのデザインとは長方形のレンガのブロックを組み立てていくようなものをイメージしないといけない。
そうすることによって変形に強い堅いバグが起こりにくいデザインとなる。
レンガに隙間が空いてるとずれたりする不条理もあるが、
floatとかposition: absolute;といったわたがしみたいな素材で建築する地獄と比べると天国。
floatはIEと共にHTMLの墓場に葬りましょう。
あと要素を動かしたい場合はjavascriptでやるのではなくcssアニメーションでtransformとかを使う。
間違ってもmarginとかwidthとかをアニメーションで変えてはダメだ。
これはレンガを動的に動かしているようなもので建物ごと崩れてしまう。
では例として下のイメージボードのデザインを参考にcssを実装してみよう。

imageBoard.png

./src/form.tsx
/** @jsx jsx */
import { jsx, css } from "@emotion/core";
import * as React from "react";
import styled from "@emotion/styled";
import { useState, useReducer } from "react";
import Button from "./button";

const Form = () => {
  const [radio, setRadio] = useState(true);
  const [text1, setText1] = useState("");
  const [text2, setText2] = useState("");
  const [isLoading, dispatch] = useReducer(
    (state: boolean, action: boolean) => action,
    false
  );
  const [serverResult, setServerResult] = useState("");

  const RadioArea = styled("div")`
    margin-top: 1rem;
    margin-bottom: 1rem;
    input:not(:first-of-type) {
      margin-left: 2rem;
    }
  `;
  const radioButtonArea = (
    <RadioArea>
      <div>Select Form Style</div>
      <input type="radio" onChange={() => setRadio(true)} checked={radio} />
      <span>Old Style</span>
      <input type="radio" onChange={() => setRadio(false)} checked={!radio} />
      <span>Modan Style</span>
    </RadioArea>
  );
  const textBox = {
    div: {
      "&:nth-of-type(2)": {
        border: "solid 1px #000000"
      }
    }
  };
  const CreateTextBoxArea = (
    props: any,
    description: string,
    message: string,
    placeholder?: string
  ) => (
    <div css={textBox}>
      <div>{description}</div>
      <div>
        <input
          size="40"
          type="text"
          {...props}
          autoComplete="off"
          placeholder={placeholder}
        />
      </div>
      <div css={{ marginLeft: "4rem" }}>{message}</div>
    </div>
  );
  const textBoxArea1HeaderFunc = (text1: string) => {
    return `textBox1`;
  };
  const textBoxArea1MessageFunc = (text1: string) => {
    return `length=${text1.length}`;
  };
  const textBoxArea2HeaderFunc = (text2: string) => {
    return `textBox2`;
  };
  const textBoxArea2MessageFunc = (text2: string) => {
    return `length=${text2.length}`;
  };
  const textBoxArea1 = CreateTextBoxArea(
    {
      value: text1,
      onChange: (e: any) => setText1(e.target.value)
    },
    textBoxArea1HeaderFunc(text1),
    textBoxArea1MessageFunc(text1)
  );
  const textBoxArea2 = CreateTextBoxArea(
    {
      value: text2,
      onChange: (e: any) => setText2(e.target.value)
    },
    textBoxArea2HeaderFunc(text2),
    textBoxArea2MessageFunc(text2)
  );

  const buttonAreaStyle = {
    height: "6rem",
    display: "flex",
    justifyContent: "center",
    alignItems: "center"
  };

  const buttonArea = (
    <div css={buttonAreaStyle}>
      <Button
        text="送信"
        isButtonOn={!isLoading}
        enable={!isLoading}
        onClick={async () => {
          dispatch(true);
          const sendVal = {
            radio: radio,
            text1: text1,
            text2: text2
          };
          const serverResult = await serverFunc(sendVal);
          setServerResult(serverResult);
          dispatch(false);
          console.log(serverResult);
        }}
      />
    </div>
  );

  const formOuter = {
    display: "flex",
    justifyContent: "center",
    alignItems: "center"
  };
  const formBorder = {
    background: "#ffe0ff",
    border: "solid 1px #a04040"
  };
  const formInner = {
    marginLeft: "3rem",
    marginRight: "3rem"
  };

  return (
    <div css={formOuter}>
      <div css={formBorder}>
        <div css={formInner}>
          {radioButtonArea}
          {textBoxArea1}
          {textBoxArea2}
          {buttonArea}
        </div>
      </div>
    </div>
  );
};
export default Form;

const serverFunc = async (json: any) => {
  const sleep = (msec: any) =>
    new Promise(resolve => setTimeout(resolve, msec));
  await sleep(1000); // サーバーからの応答待ち
  return json;
};
  • ボタンコンポーネントを分離してサーバーへのダミーリクエストをuseReducerで実装。
  • サーバー応答のダミー関数としてただ1秒待って引数をそのまま返す関数: serverFuncを作成。
  • ボタンコンポーネントにはボタンを押された時に実行される関数だけを渡す。
  • ローディング中はボタンが押せないようにする。
  • ローディング状態に入るのはサーバーへのリクエストを送ったとき。
  • ローディング状態から抜けるのはサーバーからの応答が返ってきたとき。
  • ボタンコンポーネントへは親からローディング状態かどうかを渡せばよい。

HTMLツリーに対応するcssがすぐそばにあってデザインの見通しが立ちやすいコードになってるかと思います。

TextBoxは関数化するとフォーカスが外れるのでstyled形式のcssは使えないなど制約がある。
styled形式とcssコンポーネント形式は一長一短。
キャメルケースは気持ち悪いしネストすると書きにくいというのはある。

renderはこうなる。

render.png

あとはtextBoxArea1HeaderFuncあたりをいじればリアルタイムでバリデーションを走らせることができる。

バリデーションを実装する

Old Styleの仕様を決める

石器時代っぽい仕様ってことで、こんなもんでどうだろ。

  • 上のテキストボックスは半角数字10桁のみ許容する。
  • 下のテキストボックスは全角数字10桁のみ許容する。
  • バリデーションはサーバー側のみで行う。
  • バリデーションは1つずつ行い、エラーは最初に見つかったものだけを返す。
  • エラーが起こったら既に入力済みのテキストを消す。
  • 入力の条件はプレースホルダーのみに書く。

Modan Styleの仕様を決める

令和の時代ならこれぐらいは盛り込もう。

  • サーバー側バリデーションの条件はOld Styleと同じ。
  • クライアント側の入力は半角全角の混合を許可して、サーバーに送信するときに変換する。
  • 各テキストボックスでのエラーは個別にリアルタイムで出力する。
  • サーバーに送る前に全てのバリデーションが通っている状態にする。
  • もしサーバー側でエラーが起こっても入力済みの項目は消さない。
  • 入力の条件はプレースホルダーではなく上部のヘッダー領域に書く。

サーバーからの応答に応じてフォームの内容を変更

入力を消すのはuseEffectでいけます。

import { useState, useReducer, useEffect } from "react";
useEffect(() => {
    if (radio) {
      setText1("");
      setText2("");
    }
    const jsonString = JSON.stringify(serverResult, undefined, 2);
    if (jsonString.length > 2) {
      alert(jsonString);
    }
  }, [serverResult]);

上のコードでserverResultが変更されたらuseEffect内を実行するが実現できます。
useStateの関数でフォームの文字列に空文字をセットしているのでテキストがクリアされることになります。

サーバーでのバリデーション

JSONを受け取ってJSONでresultを返すのだからこんなもんだろう。

const serverFunc = async (json: any) => {
  const sleep = (msec: any) =>
    new Promise(resolve => setTimeout(resolve, msec));
  await sleep(1000); // サーバーからの応答待ち
  const jsonKeyCheck = "radio" in json && "text1" in json && "text2" in json;
  if (!jsonKeyCheck) {
    return { result: false, str: "不正なJSONです" };
  }
  const regText1 = /^[0-9]{10}$/;
  const regText2 = /^[0-9]{10}$/;
  if (json.radio) {
    console.log(json.text1);
    if (!regText1.test(json.text1)) {
      return { result: false, str: `${json.text1}は半角10桁ではありません` };
    }
    if (!regText2.test(json.text2)) {
      return { result: false, str: `${json.text2}は全角10桁ではありません` };
    }
  } else {
    let errorStr = "";
    if (!regText1.test(json.text1)) {
      errorStr += `${json.text1}は半角10桁ではありません\n`;
    }
    if (!regText2.test(json.text2)) {
      errorStr += `${json.text2}は全角10桁ではありません\n`;
    }
    if (errorStr.length > 0) {
      return { result: false, str: `${errorStr.slice(0, -1)}` };
    }
  }
  return { result: true, ...json };
};

バリデーション

  const regText = /^[0-90-9]{10}$/;
  const isValid = regText.test(text1) && regText.test(text2);
  const convertHankaku = (e: string) =>
    e.replace(/[A-Za-z0-9]/g, (s: string) =>
      String.fromCharCode(s.charCodeAt(0) - 65248)
    );
  const convertZenkaku = (e: string) =>
    e.replace(/[A-Za-z0-9]/g, (s: string) =>
      String.fromCharCode(s.charCodeAt(0) + 65248)
    );
  const textBoxArea1HeaderFunc = (text1: string) => {
    return radio ? "入力項目1" : "入力項目1: 10桁の数字で入力してください";
  };
  const textBoxArea1MessageFunc = (text1: string) => {
    if (radio) {
      return "";
    }
    if (text1.length != 10) {
      return `${text1.length}文字が入力されています。`;
    }
    if (!regText.test(text1)) {
      return "数字10桁ではありません";
    }
    return `length=${text1.length}`;
  };
  const textBoxArea2HeaderFunc = (text2: string) => {
    return radio ? "入力項目2" : "入力項目2: 10桁の数字で入力してください";
  };
  const textBoxArea2MessageFunc = (text2: string) => {
    if (radio) {
      return "";
    }
    if (text2.length != 10) {
      return `${text2.length}文字が入力されています。`;
    }
    if (!regText.test(text2)) {
      return "数字10桁ではありません";
    }
    return `length=${text2.length}`;
  };
  const textBoxArea1 = CreateTextBoxArea(
    {
      value: text1,
      onChange: (e: any) => setText1(e.target.value)
    },
    textBoxArea1HeaderFunc(text1),
    textBoxArea1MessageFunc(text1),
    radio ? "10桁の半角数字で入力してください" : "入力してください"
  );
  const textBoxArea2 = CreateTextBoxArea(
    {
      value: text2,
      onChange: (e: any) => setText2(e.target.value)
    },
    textBoxArea2HeaderFunc(text2),
    textBoxArea2MessageFunc(text2),
    radio ? "10桁の全角数字で入力してください" : "入力してください"
  );

ボタンをこんな感じにすれば、OldStyleなら全通しでModanStyleだとバリデーション通ってないと押せなくなる。
サーバーに渡す値もModanStyleのときだけ変換をかける。

<Button
  text="送信"
  isButtonOn={!isLoading}
  enable={!isLoading && (radio || isValid)}
  onClick={async () => {
    dispatch(true);
    const sendVal = {
      radio: radio,
      text1: radio ? text1 : convertHankaku(text1),
      text2: radio ? text2 : convertZenkaku(text2)
    };
    const serverResult = await serverFunc(sendVal);
    setServerResult(serverResult);
    dispatch(false);
    console.log(serverResult);
  }}
/>

しっかり変換されてますね。

result.png

まとめ

全体的にHooksをフル活用してできている。
これだけの機能を持ったフォームなのにコンポーネントじゃないのはすごい。
2015年ぐらいから見違えるほど進歩してますね。

10
10
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
10
10