【Reactレシピ】バリデーションつき入力欄コンポーネントをいい感じに作る。〜実施編:フラットな実装をコンポーネント化する〜

こんにちは。

油淋鶏を作ったのですが、包丁の切れ味が落ちていて最後の切り分けのときにグチャッとなってしまったはこだてたろうです。
食後にしっかり研ぎました。

目次

はじめに

さて、「バリデーションつき入力欄コンポーネントをいい感じに作る」の第2回です。

前回は準備編として、コンポーネント化されていない状態の画面を作成しました。

今回は実施編ということで、コンポーネント化されていないフラットなコードをいい感じにコンポーネント化していきたいと思います。

実物はこちら

あわせて読みたい
React Recipe Reactアプリのレシピ集

リポジトリはこちら

GitHub
GitHub - hakoratory/react-recipe Contribute to hakoratory/react-recipe development by creating an account on GitHub.

この記事を読むことで作れるもの

この記事を読むことで、以下のような画面が作れるようになります。

実装

先にコード全容です。

コンポーネントをいくつか作ったので前回と比べてコード量は増えています。

いまは入力欄が2つしかないのでコンポーネント化の手間の方が目立っていますが、今後画面が大きくなって入力欄が増えたときにその手間は逆転するでしょう。

例えば、仕様変更が発生した場合、しっかりとコンポーネント化ができていなければ、同じ修正を何箇所にも施さないといけなくなってしまいます。

そのようなことにならないよう、何個も同じようなものがある場合はしっかりとコンポーネント化していきましょう。

'use client'

import React, { useState, useId, ChangeEvent, FormEvent } from "react";
import './style.css'

function Label({htmlFor, text}: {htmlFor?: string; text: string;}) {
  return <label htmlFor={htmlFor}>{text}</label>
}

function TextInput({id, value, onChange, placeholder}: {
  id?: string;
  value: string;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  placeholder?: string;
}) {
  return <input
    id={id}
    type="text"
    value={value}
    onChange={onChange}
    placeholder={placeholder}
  />
}

function ErrorMessage({text}: {text: string}) {
  return <span style={{color: 'red'}}>{text}</span>
}

function LabeledTextInput({id, label, value, onChange, placeholder}: {
  id?: string;
  label: string;
  value: string;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  placeholder?: string;
}) {
  const uniqueId = useId()
  return (
    <>
      <Label htmlFor={id ?? uniqueId} text={label}/>
      <div>
        <TextInput
          id={id ?? uniqueId}
          value={value}
          onChange={onChange}
          placeholder={placeholder}
        />
      </div>
    </>
  )
}

interface PatternValidation {
  regex: RegExp;
  message: string;
}

function LabeledTextInputWithValidation(
  {
    id,
    label,
    value,
    onChange,
    placeholder,
    required,
    max,
    min,
    pattern
  }: {
    id?: string;
    label: string;
    value: string;
    onChange: (event: ChangeEvent<HTMLInputElement>) => void;
    placeholder?: string;
    required?: true;
    max?: number;
    min?: number;
    pattern?: PatternValidation[];
  }
  ) {
  const [error, setError] = useState<string>('');

  const validate = (value: string): string => {
    if (required && value.trim() === '') {
      return `必須項目です。`;
    }
    if (max !== undefined && value.length > max) {
      return `${max}文字以内で入力してください。`;
    }
    if (min !== undefined && value.length < min) {
      return `${min}文字以上で入力してください。`;
    }
    if (pattern) {
      for (const { regex, message } of pattern) {
        if (!regex.test(value)) {
          return message;
        }
      }
    }
    return '';
  };

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const newValue = event.target.value;
    onChange(event);
    const errorMessage = validate(newValue);
    setError(errorMessage);
  };

  return (
    <>
      <LabeledTextInput
        id={id}
        label={label}
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
      />
      <div style={{color: 'red', fontSize: '1rem', minHeight: '1.5rem', marginTop: '0.5rem'}}>
        <ErrorMessage text={error}/>
      </div>
    </>
  )
}

function InputFormComponent() {
  const [name, setName] = useState<string>('');
  const [nameKana, setNameKana] = useState<string>('');

  const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
    event.preventDefault();
    console.log({ name, nameKana });
  };

  return (
    <main>
      <div style={{marginTop: '2rem', marginLeft: '2rem'}}>
        <form onSubmit={handleSubmit}>
          <div>
            <LabeledTextInputWithValidation
              label={'名前'}
              value={name}
              onChange={(event: ChangeEvent<HTMLInputElement>) => setName(event.target.value)}
              placeholder={'名前を入力してください'}
              required
              max={50}
              min={2}
            />
          </div>
          <div style={{marginTop: '1rem'}}>
            <LabeledTextInputWithValidation
              label={'名前(カナ)'}
              value={nameKana}
              onChange={(event: ChangeEvent<HTMLInputElement>) => setNameKana(event.target.value)}
              placeholder={'名前(カナ)を入力してください'}
              required
              max={50}
              min={2}
              pattern={[
                {regex: /^[ァ-ヴ]+$/, message: 'カタカナのみ使用できます'}
              ]}
            />
          </div>
          <div style={{marginTop: '1rem'}}>
            <button type="submit">SUBMIT</button>
          </div>
        </form>
      </div>
    </main>
  );
}

export default InputFormComponent;

各部分の解説

完成したコンポーネント LabeledTextInputWithValidation

まず、最終的に完成したコンポーネント LabeledTextInputWithValidation について見ていきます。

function LabeledTextInputWithValidation(
  {
    id,
    label,
    value,
    onChange,
    placeholder,
    required,
    max,
    min,
    pattern
  }: {
    id?: string;
    label: string;
    value: string;
    onChange: (event: ChangeEvent<HTMLInputElement>) => void;
    placeholder?: string;
    required?: true;
    max?: number;
    min?: number;
    pattern?: PatternValidation[];
  }
  ) {
  const [error, setError] = useState<string>('');

  const validate = (value: string): string => {
    if (required && value.trim() === '') {
      return `必須項目です。`;
    }
    if (max !== undefined && value.length > max) {
      return `${max}文字以内で入力してください。`;
    }
    if (min !== undefined && value.length < min) {
      return `${min}文字以上で入力してください。`;
    }
    if (pattern) {
      for (const { regex, message } of pattern) {
        if (!regex.test(value)) {
          return message;
        }
      }
    }
    return '';
  };

  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
    const newValue = event.target.value;
    onChange(event);
    const errorMessage = validate(newValue);
    setError(errorMessage);
  };

  return (
    <>
      <LabeledTextInput
        id={id}
        label={label}
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
      />
      <div style={{color: 'red', fontSize: '1rem', minHeight: '1.5rem', marginTop: '0.5rem'}}>
        <ErrorMessage text={error}/>
      </div>
    </>
  )
}

ポイントは、必須や文字数チェックといったよくあるバリデーションを props で指定できるようにしている点です。
required max min を指定すると、以下の必須チェックや最大・最小文字数チェックを行います。

  const validate = (value: string): string => {
    if (required && value.trim() === '') {
      return `必須項目です。`;
    }
    if (max !== undefined && value.length > max) {
      return `${max}文字以内で入力してください。`;
    }
    if (min !== undefined && value.length < min) {
      return `${min}文字以上で入力してください。`;
    }
    if (pattern) {
      for (const { regex, message } of pattern) {
        if (!regex.test(value)) {
          return message;
        }
      }
    }
    return '';
  };

また、pattern に正規表現とエラーメッセージを渡すことで、任意のバリデーションを行うことができます。

            <LabeledTextInputWithValidation
              label={'名前(カナ)'}
              value={nameKana}
              onChange={(event: ChangeEvent<HTMLInputElement>) => setNameKana(event.target.value)}
              placeholder={'名前(カナ)を入力してください'}
              required
              max={50}
              min={2}
              pattern={[
                {regex: /^[ァ-ヴ]+$/, message: 'カタカナのみ使用できます'}
              ]}
            />

ラベルと入力欄、エラーメッセージのコンポーネント

次にLabeledTextInputWithValidation コンポーネントを構成する最小単位のコンポーネントを見ていきます。

ラベルは Label コンポーネントです。
その名のとおり label 要素を返します。
ラベルをクリックすることで入力欄にフォーカスできるよう props.htmlFor を指定するようにしています。

なお、本来のHTMLの label 要素ではfor を使いますが、for は JavaScript の予約語なので React では使えません。
そのため、React では htmlFor を使います。

function Label({htmlFor, text}: {htmlFor?: string; text: string;}) {
  return <label htmlFor={htmlFor}>{text}</label>
}

入力欄は TextInput コンポーネントです。
props.id はラベルをクリックしたときに入力欄にフォーカスを当てるために指定します。

function TextInput({id, value, onChange, placeholder}: {
  id?: string;
  value: string;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  placeholder?: string;
}) {
  return <input
    id={id}
    type="text"
    value={value}
    onChange={onChange}
    placeholder={placeholder}
  />
}

エラーメッセージは ErrorMessage コンポーネントです。
文字サイズなどは呼び出し側で指定するようにしているので、ここでは文字色を赤色にするのみです。

function ErrorMessage({text}: {text: string}) {
  return <span style={{color: 'red'}}>{text}</span>
}

ラベルと入力欄を合わせた LabeledTextInput

ラベルつきの入力欄は、Label コンポーネントと TextInput コンポーネントを組み合わせて作りました。

function LabeledTextInput({id, label, value, onChange, placeholder}: {
  id?: string;
  label: string;
  value: string;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
  placeholder?: string;
}) {
  const uniqueId = useId()
  return (
    <>
      <Label htmlFor={id ?? uniqueId} text={label}/>
      <div>
        <TextInput
          id={id ?? uniqueId}
          value={value}
          onChange={onChange}
          placeholder={placeholder}
        />
      </div>
    </>
  )
}

ラベルと入力欄は横並びにしたくなかったので、TextInputdiv で囲みました。

Labelprops.htmlFor および TextInputprops.id には、LabeledTextInputprops.id を渡します。
LabeledTextInputprops.id が渡されなかった場合でもラベルクリックに入力欄が反応するよう、useId() で生成したユニークIDを渡すようにしています。

レイアウトの制御はコンポーネントの呼び出し側で行う

LabelTextInputErrorMessage では、レイアウトの制御をしていないことに注目してください。

レイアウトの制御は、これらのコンポーネントを束ねている LabeledTextInputLabeledTextInputWithValidation の仕事です。

  // LabeledTextInput
  return (
    <>
      <Label htmlFor={id ?? uniqueId} text={label}/>
      <div>
        <TextInput
          id={id ?? uniqueId}
          value={value}
          onChange={onChange}
          placeholder={placeholder}
        />
      </div>
    </>
  )
  // LabeledTextInputWithValidation
  return (
    <>
      <LabeledTextInput
        id={id}
        label={label}
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
      />
      <div style={{color: 'red', fontSize: '1rem', minHeight: '1.5rem', marginTop: '0.5rem'}}>
        <ErrorMessage text={error}/>
      </div>
    </>
  )

おわりに

いかがだったでしょうか?

今回は実施編として、コンポーネント化されていないフラットなコードをいい感じにコンポーネント化してみました。

コードもスッキリしましたし、カスタムバリデーションも行うことができるので、まぁまぁいい感じになったのではないかと思います。
仕様変更があったときに「本当に直しやすいのか?」というのが気になるところですね。
その検証はまたの機会に・・・。

ハコラトリでは、「できる」を増やしてワクワクする人生を送るための情報をシェアしています。
ぜひ一緒にワクワクな人生を送れるように「できる」を増やしていきましょう。

これからもReactで色々なUIを作って紹介していきますので、「こんなUIを作ってみてほしい」「こんな場合はどうすれば?」といったアイディアを募集中です!
コメントお待ちしております。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

28歳のときに音楽家からエンジニアに転向。
WEB系のシステム開発(Java, C#, Vue, React など)に携わっています。

「できる」を増やしてワクワクな人生を送るための情報をシェアしています。

コメント

コメントする

目次