Chapter 14

再実例:Reactでフォームの入力値バリデーション

とっくり
とっくり
2022.08.10に更新

前回のReactの話の続きです。

https://zenn.dev/tockri/books/dcaf6c55e64448/viewer/react
今回はずっと以前にjQueryで実装したフォームの入力値バリデーションを今度はReactで作ってみます。

おさらい:2019年 jQueryバージョンの概要

前に作ったやつ(jQueryバージョン)はこちらの記事で作ったものです。

  1. 実例:フォームの入力値バリデーション
    • ちょっと複雑な入力値バリデーションを行うフォームを作りたい!
    • 入力値とエラーの有無とエラーメッセージを持つStateという型を導入しました。
    • 全てのチェック処理を、(State) => State型の純粋関数として実装しました。
    • DOMアクセス(副作用)を処理の最初と最後に集めました。
  2. 実例:単体テストとリファクタリング #1
    • 純粋関数なので、Jestで高速にテストできてハッピー!
  3. 実例:単体テストとリファクタリング #2
    • 関数の組み合わせを利用して重複した処理を共通化しました。
  4. 実例:単体テストとリファクタリング #3 〜仕様追加〜
    • エラーチェックに加えて、全角英数→ASCII変換、大文字→小文字変換、郵便番号のハイフン無し→有り変換も加えました。
  5. 実例:単体テストとリファクタリング #4 〜カリー化と結合〜
    • 最終的にカリー化とpipeを使った関数合成で関数型プログラミングらしいコードにリファクタリングしました。

jQueryバージョンの仕様とライブラリ構成

プロパティ 入力終わり
name
名前
必須チェック
mailAddress
メールアドレス
必須チェック
全角英数→ASCII変換
大文字→小文字変換
形式チェック
zipCode
郵便番号
必須チェック
全角英数→ASCII変換
ハイフン無し→有り変換
形式チェック
address
住所
必須チェック
全角英数→ASCII変換

jQueryバージョンのデモ

できあがりのコードとデモです。

<input>onChangeイベントで、Validator.validate***といったチェック関数が実行されます。「Open Sandbox」ボタンからプロジェクト全体を見られます。

2022年 Reactバージョンの概要

  • 前回と同じ仕様でもっと関数型の魅力満載にする
  • Reactのコンポーネント関数をちゃんと前回の記事で書いたような(ほぼ)純粋関数にする

Reactバージョンの仕様とライブラリ構成

Reactで<input>を扱う場合、1文字入力するごとにonChangeイベントを処理するのがよくあるパターンですが、ここで入力値を加工すると日本語入力のユーザー体験がとても悪くなるため、入力中と入力終わりの2種類のイベントに処理を分けることにします。

プロパティ 入力中 入力終わり
name
名前
必須チェック --
mailAddress
メールアドレス
必須チェック 全角英数→ASCII変換
大文字→小文字変換
形式チェック
zipCode
郵便番号
必須チェック 全角英数→ASCII変換
ハイフン無し→有り変換
形式チェック
address
住所
必須チェック 全角英数→ASCII変換
  • TypeScript
  • React 18
  • MUI

できあがり

1文字入力するごとにチェックが走る以外はほぼjQueryバージョンと同じ動作です。「Open Sandbox」ボタンからプロジェクト全体を見られます。

解説

小さな構造から大きな構造の順に4ステップで説明していきます。

1. Validated

一つの入力値に対してバリデーションチェックと値の変換を行うのに便利な入れ物です。

jQueryバージョンでは「エラーがない」ことを示すにはisValidという属性をtrueとしていましたが、今回のバージョンではMUIのTextFieldの仕様にフィットするようhasErrorという属性名でtrue/falseを逆にしました。

さりげなく実装に都合がいいように、それでいて実装ライブラリに依存してしまわないような作りにしているのがニクいですね。

チェックや変換を行う更新関数は、引数がValidated<T>で返り値もValidated<T>とすると後で合成するのに便利なのでした。export type ValidationFunc<T>として関数の型に名前をつけてexportしておきます。

2. ValidationFunc

フォームの仕様を分析すると、

  • 必須チェック
  • 全角英数→ASCIIに変換
  • 大文字→小文字に変換
  • 郵便番号のハイフン無し→有りに変換
  • 入力値が正規表現に一致しているかチェック
    • →メールアドレスの形式チェック
    • →郵便番号の形式チェック

という基本的な機能が必要だとわかるので、ValidationFunc<string>型の関数としてそれぞれ実装します。

正規表現に一致しているかチェックするcheckPatternは、jQueryバージョンでやったようにカリー化(っぽく関数を返す関数に)して、正規表現とエラーメッセージを渡すとValidationFunc型の関数を返すような関数としています。

もちろん、全て純粋関数なので、テストも簡単です。validationFuncs.test.tsで、サードパーティのライブラリなど一切無しで十分なテストを非常に楽に実装できました。

3. FormData

フォームの内容(名前、メールアドレス、郵便番号、住所)を含むオブジェクトFormData型を考えます。もちろん各プロパティはValidated型ですね。

export type FormData = {
  readonly name: Validated<string>
  readonly mailAddress: Validated<string>
  readonly zipCode: Validated<string>
  readonly address: Validated<string>
}

最終的にはこのFormDataをReactのuseStateで扱って、入力フィールドのonChangeイベントで更新する仕組みにすればいいわけです。

Reactのコンポーネントを作り始める前に、もう少し準備しましょう。

/**
 * @param curr 現在のFormData
 * @param value 入力フィールドの入力値
 * @return 更新後のFormData
 */
type FormDataSetter = (curr: FormData, value: string) => FormData

という関数を組み立てていきます。まだReactに依存しないので拡張子は.tsです。

この仕様どおりに上で作ったValidationFunc関数を組み合わせてFormDataSetter関数にするための仕掛けをこんなふうに用意して…

/**
 * @param key FormDataのどのプロパティを更新するか
 * @param ...functions keyのプロパティに対して順番に適用するValidationFunc
 */
const setter =
  (
    key: keyof FormData,
    ...functions: ValidationFunc<string>[]
  ): FormDataSetter =>
  (curr, value) => ({
    ...curr,
    [key]: pipe(...functions)(valid(value)),
  })

たとえばメールアドレスの入力終わりに実行するFormDataSetter関数をこうやって組み立てます。

/**
 * FormData.mailAddressの入力終わりに
 * 全角英数→ASCII、大文字→小文字、形式チェック
 * を適用するFormDataSetter関数
 */
const setMailAddressOnFinish = setter(
  'mailAddress',
  normalizeToAscii,
  normalizeToLower,
  checkPattern(
    /^[\w.]+@[\w.]+[^.]$/, // この正規表現は嘘なので使ってはいけません。
    'メールアドレスの形式が正しくありません'
  )
)

・・・ところでこのコード、良くないですか?

setterが「なんじゃこりゃ」と思ったとしても、

  1. フォームのmailAddressを入力し終わったときに
  2. normalizeToAsciiで全角を半角に変換し、
  3. normalizeToLowerで大文字を小文字に変換し、
  4. checkPatternで正規表現に一致していなかったらエラーメッセージを返す

らしいことがコメントを読まなくても一目瞭然です。

逆に、仕様を確認してからこれを書き上げるまでに(setterの書き方を知っていれば)何も考えずに書けます。ローカル変数もループも条件分岐もないので間違えようがありません。

同様に他のプロパティについても実装していき、必要な全ての関数を揃えました。全部純粋関数です。

もちろん、テストもバッチリです。formData.test.tsで見ることができます。

(ポエム)共通化しすぎない、関心事の分離

さきほどフォーム仕様を表で整理しましたが、どのフィールドも入力1文字ごとに行うのは「必須チェックのみ」で共通になっています。しかし、

const setNameOnTyping: FormDataSetter
const setMailAddressOnTyping: FormDataSetter
const setZipCodeOnTyping: FormDataSetter
const setAddressOnTyping: FormDataSetter

という関数は共通化してしまわず、ちゃんと残します。こうすることで、この後作るReactコンポーネントで「nameの入力中にどんなチェックをするか」の内容について何も知らなくて良くなります。関心事の分離というやつですね。

コンポーネントを作るときは「nameの入力中にはsetNameOnTypingを呼ぶ」ことだけわかっていれば良いのです。

これを「nameの入力中にはcheckEmptyを呼ぶ」としてしまうと、コンポーネントの方にフォームのバリデーション仕様の知識が漏れ出してしまうことになります。関数名もりっぱな知識ですからね。

実際、今回4つの階層の関数群を作りましたが、各階層は上や下の階層について知りません。

関心事の分離はべつに関数型プログラミングに限った話ではないのですが、細かい関数の組み合わせで大きな関数を作るという関数型プログラミングのやり方は関心事の分離に利用しやすいです。

そして、関数型プログラミングについて学習すると、この関数の組み合わせのレパートリーがめちゃくちゃ増えます。

もし関数型プログラミングの知識がなければ、今回使ったようなpipesetter、カリー化などのテクニックはなかなか自分では思いつきません。結果、こんなにうまく関心事を細かく分離しつつコードを短くすることは難しかったでしょう。

4. Reactコンポーネント

Reactコンポーネントでやることは、ここまで作った純粋関数たちをReact.useStateとDOMイベントにくっつけるだけです。

FormData型のStateを用意して…

const [formData, setFormData] = useState<FormData>(initialFormData)

「TextFieldのイベントオブジェクトからvalueを取り出して、FormDataSetter型の関数で処理してsetFormDataに渡す」というコードを短く書くための仕掛けを作ります。

const listener =
  (setter: FormDataSetter) => (e: { target: { value: string } }) =>
    setFormData((curr) => setter(curr, e.target.value))

これにより、TextFieldのonChangeイベントとonBlurイベントにはめこむコードが短くなります。

<TextField
  :
  onChange={listener(setMailAddressOnTyping)}
  onBlur={listener(setMailAddressOnFinish)}
  :
  />

各フィールドのUIをMUIで作って完成です。

Validator型でエラー有無を表すプロパティをhasErrorという名前にしたので、TextFieldの仕様にフィットしてますね。

UserApplicationFormコンポーネント関数の中でuseState以外の副作用を全く起こさない「ほぼ純粋関数」になっているので、Storybookに載せても、CodeSandboxに載せても、ちゃんと仕様どおりに動くことが保証されます。めでたし。