こんにちは。
油淋鶏を作ったのですが、包丁の切れ味が落ちていて最後の切り分けのときにグチャッとなってしまったはこだてたろうです。
食後にしっかり研ぎました。
はじめに
さて、「バリデーションつき入力欄コンポーネントをいい感じに作る」の第2回です。
前回は準備編として、コンポーネント化されていない状態の画面を作成しました。

今回は実施編ということで、コンポーネント化されていないフラットなコードをいい感じにコンポーネント化していきたいと思います。
実物はこちら
リポジトリはこちら
この記事を読むことで作れるもの
この記事を読むことで、以下のような画面が作れるようになります。

実装
先にコード全容です。
コンポーネントをいくつか作ったので前回と比べてコード量は増えています。
いまは入力欄が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>
</>
)
}
ラベルと入力欄は横並びにしたくなかったので、TextInput
を div
で囲みました。
Label
の props.htmlFor
および TextInput
の props.id
には、LabeledTextInput
の props.id
を渡します。LabeledTextInput
に props.id
が渡されなかった場合でもラベルクリックに入力欄が反応するよう、useId()
で生成したユニークIDを渡すようにしています。
レイアウトの制御はコンポーネントの呼び出し側で行う
Label
や TextInput
、ErrorMessage
では、レイアウトの制御をしていないことに注目してください。
レイアウトの制御は、これらのコンポーネントを束ねている LabeledTextInput
と LabeledTextInputWithValidation
の仕事です。
// 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を作ってみてほしい」「こんな場合はどうすれば?」といったアイディアを募集中です!
コメントお待ちしております。
コメント