バリデーションの基礎:入力ミスを防ぐ仕組みを作る

はじめに
この節では、フォームに バリデーション を追加します。
バリデーションとは、入力された内容が正しいかどうかをチェックする仕組みです。
たとえば、次のようなミスを防ぎます。
スポット名が空のまま
説明文が短すぎる
カテゴリが選ばれていない
画像URLがURLの形になっていない
これを入れると、ユーザーが入力ミスをしたときに、
「どこを直せばよいか」がすぐわかるようになります。
この節のゴール
この節が終わると、次のことができるようになります。
- 未入力チェックができる
- 文字数チェックができる
- 不正な入力を防げる
- エラーメッセージを表示できる
- 送信前にフォーム内容を確認できる 今日の合言葉はこれです。
送信する前に、入力ミスをチェックする。
1. バリデーションとは
バリデーションとは、入力内容が正しいかどうかを確認することです。
たとえば、スポット名が空のまま投稿されたら困ります。
スポット名: 空
説明文: 空
カテゴリ: 図書館
場所: 空
画像URL: abc
このまま投稿されると、一覧画面で何のスポットかわかりません。
そこで、投稿ボタンを押したときにチェックします。
スポット名は入力されている?
説明文は短すぎない?
場所は入力されている?
画像URLは https:// から始まっている?
問題があれば、エラーメッセージを出します。
2. 今回チェックする内容
今回は、必要最低限のチェックだけを入れます。
| 項目 | チェック内容 | エラー例 |
|---|---|---|
| スポット名 | 空ではないか | スポット名を入力してください |
| スポット名 | 50文字以内か | 50文字以内で入力してください |
| 説明文 | 空ではないか | 説明文を入力してください |
| 説明文 | 20文字以上か | 20文字以上で入力してください |
| カテゴリ | 選ばれているか | カテゴリを選択してください |
| 場所 | 空ではないか | 場所を入力してください |
| 画像URL | URL形式か | http または https から始めてください |
これだけでも、フォームとしてかなり使いやすくなります。
3. エラーを保存する state を作る
まず、エラーメッセージを保存するための state を作ります。
今回は、項目ごとにエラーを持ちます。
type FormErrors = {
name: string;
description: string;
category: string;
location: string;
imageUrl: string;
};
エラーがないときは、空文字にします。
const emptyErrors: FormErrors = {
name: '',
description: '',
category: '',
location: '',
imageUrl: '',
};
そして、useState で管理します。
const [errors, setErrors] = useState<FormErrors>(emptyErrors);
なぜ項目ごとに分けるのか
項目ごとに分けると、表示がわかりやすくなります。
name のエラー → スポット名の下に表示
description のエラー → 説明文の下に表示
imageUrl のエラー → 画像URLの下に表示
これで、ユーザーは「どこを直せばよいか」がすぐわかります。
4. バリデーション関数を作る
次に、入力チェックを行う関数を作ります。
関数名は validateForm にします。
/**
* 役割: 入力内容を検証し、エラーメッセージを更新する
* 入力: なし
* 出力: 入力内容が正しければ true、エラーがあれば false
*/
const validateForm = (): boolean => {
const nextErrors: FormErrors = {
name: '',
description: '',
category: '',
location: '',
imageUrl: '',
};
if (name.trim().length === 0) {
nextErrors.name = 'スポット名を入力してください。';
}
setErrors(nextErrors);
return nextErrors.name.length === 0;
};
最初は、スポット名だけでも大丈夫です。
あとから少しずつ増やします。
5. 未入力チェック
未入力チェックでは、空かどうかを見ます。
if (name.trim().length === 0) {
nextErrors.name = 'スポット名を入力してください。';
}
ここで使っている trim() は、前後の空白を消すものです。
たとえば、ユーザーが半角スペースだけを入力した場合、
" "
そのままだと文字があるように見えます。
でも trim() を使うと空になります。
" ".trim()
結果は空文字です。
""
だから、未入力チェックでは trim() を使うのが安全です。
6. 文字数チェック
次に、文字数をチェックします。
スポット名は長すぎると見づらくなります。
今回は50文字以内にします。
if (name.trim().length > 50) {
nextErrors.name = 'スポット名は50文字以内で入力してください。';
}
説明文は短すぎると内容が伝わりません。
今回は20文字以上にします。
if (description.trim().length < 20) {
nextErrors.description = '説明文は20文字以上で入力してください。';
}
ただし、説明文が空のときは、まず未入力エラーを出します。
if (description.trim().length === 0) {
nextErrors.description = '説明文を入力してください。';
} else if (description.trim().length < 20) {
nextErrors.description = '説明文は20文字以上で入力してください。';
}
このように、else if を使うと、エラーが重なりにくくなります。
7. 不正なURLを防ぐ
画像URLは、URLの形になっているか確認します。
今回は、必要最低限として、http:// または https:// から始まっているかを見ます。
const isValidImageUrl = /^https?:\/\/.+/u.test(imageUrl.trim());
このチェックで、次のようなURLはOKです。
https://example.com/image.jpg
http://example.com/image.jpg
次のような文字はNGです。
example.com/image.jpg
画像です
abc
画像URLは必須ではなく、入力された場合だけチェックします。
if (imageUrl.trim().length > 0) {
const isValidImageUrl = /^https?:\/\/.+/u.test(imageUrl.trim());
if (!isValidImageUrl) {
nextErrors.imageUrl =
'画像URLは http または https から始めてください。';
}
}
8. エラーメッセージを表示する
エラーは、入力欄の下に表示します。
{errors.name.length > 0 ? (
<p style={{ color: '#b22323', fontSize: '14px' }}>
{errors.name}
</p>
) : null}
意味はこうです。
errors.name に文字がある
→ エラーを表示する
errors.name が空
→ 何も表示しない
赤い文字で出すと、ユーザーが気づきやすくなります。
9. 送信時にチェックする
フォーム送信時に、まず validateForm() を呼びます。
const isValid = validateForm();
if (!isValid) {
return;
}
これで、エラーがある場合は投稿処理に進みません。
流れはこうです。
投稿ボタンを押す
↓
validateForm が動く
↓
エラーがある
↓
エラー表示
↓
投稿処理は止まる
エラーがなければ、投稿処理へ進みます。
10. 完成コード
app/spots/new/page.tsx を次の内容に置き換えてください。
app/spots/new/page.tsx
'use client';
import { useState } from 'react';
type FormErrors = {
name: string;
description: string;
category: string;
location: string;
imageUrl: string;
};
const emptyErrors: FormErrors = {
name: '',
description: '',
category: '',
location: '',
imageUrl: '',
};
/**
* 役割: 新しいスポットを投稿するフォーム画面を表示し、入力内容を検証する
* 入力: なし
* 出力: バリデーション付きの新規投稿フォーム画面
*/
export default function NewSpotPage(): JSX.Element {
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [category, setCategory] = useState<string>('');
const [location, setLocation] = useState<string>('');
const [imageUrl, setImageUrl] = useState<string>('');
const [errors, setErrors] = useState<FormErrors>(emptyErrors);
/**
* 役割: 入力内容を検証し、エラーメッセージを更新する
* 入力: なし
* 出力: 入力内容が正しければ true、エラーがあれば false
*/
const validateForm = (): boolean => {
const nextErrors: FormErrors = {
name: '',
description: '',
category: '',
location: '',
imageUrl: '',
};
if (name.trim().length === 0) {
nextErrors.name = 'スポット名を入力してください。';
} else if (name.trim().length > 50) {
nextErrors.name = 'スポット名は50文字以内で入力してください。';
}
if (description.trim().length === 0) {
nextErrors.description = '説明文を入力してください。';
} else if (description.trim().length < 20) {
nextErrors.description = '説明文は20文字以上で入力してください。';
}
if (category.trim().length === 0) {
nextErrors.category = 'カテゴリを選択してください。';
}
if (location.trim().length === 0) {
nextErrors.location = '場所を入力してください。';
}
if (imageUrl.trim().length > 0) {
const isValidImageUrl = /^https?:\/\/.+/u.test(imageUrl.trim());
if (!isValidImageUrl) {
nextErrors.imageUrl =
'画像URLは http または https から始めてください。';
}
}
setErrors(nextErrors);
return Object.values(nextErrors).every((errorMessage) => {
return errorMessage.length === 0;
});
};
/**
* 役割: フォーム送信時に入力内容を検証し、問題がなければ投稿内容を受け取る
* 入力: event - フォーム送信イベント
* 出力: なし
*/
const handleSubmit = (event: React.FormEvent<HTMLFormElement>): void => {
event.preventDefault();
const isValid = validateForm();
if (!isValid) {
return;
}
const newSpot = {
name: name.trim(),
description: description.trim(),
category: category.trim(),
location: location.trim(),
imageUrl: imageUrl.trim(),
};
console.log('投稿内容:', newSpot);
alert('入力チェックOKです。投稿内容を受け取りました。');
};
return (
<main
style={{
maxWidth: '720px',
margin: '0 auto',
padding: '32px',
}}
>
<h1>新しいスポットを投稿する</h1>
<p>入力ミスがある場合は、投稿前にエラーメッセージを表示します。</p>
<form
onSubmit={handleSubmit}
style={{
display: 'grid',
gap: '16px',
marginTop: '24px',
}}
>
<label style={{ display: 'grid', gap: '8px' }}>
<span>スポット名</span>
<input
type="text"
value={name}
onChange={(event) => {
setName(event.target.value);
}}
placeholder="例)静かな図書館"
style={{
padding: '12px',
border: errors.name.length > 0 ? '1px solid #b22323' : '1px solid #ccc',
borderRadius: '8px',
}}
/>
{errors.name.length > 0 ? (
<p style={{ margin: 0, color: '#b22323', fontSize: '14px' }}>
{errors.name}
</p>
) : null}
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>説明文</span>
<textarea
value={description}
onChange={(event) => {
setDescription(event.target.value);
}}
placeholder="例)とても静かで、勉強や読書に集中できます。"
rows={5}
style={{
padding: '12px',
border:
errors.description.length > 0
? '1px solid #b22323'
: '1px solid #ccc',
borderRadius: '8px',
}}
/>
{errors.description.length > 0 ? (
<p style={{ margin: 0, color: '#b22323', fontSize: '14px' }}>
{errors.description}
</p>
) : null}
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>カテゴリ</span>
<select
value={category}
onChange={(event) => {
setCategory(event.target.value);
}}
style={{
padding: '12px',
border:
errors.category.length > 0
? '1px solid #b22323'
: '1px solid #ccc',
borderRadius: '8px',
}}
>
<option value="">選択してください</option>
<option value="図書館">図書館</option>
<option value="カフェ">カフェ</option>
<option value="学食">学食</option>
<option value="作業スペース">作業スペース</option>
<option value="休憩スポット">休憩スポット</option>
</select>
{errors.category.length > 0 ? (
<p style={{ margin: 0, color: '#b22323', fontSize: '14px' }}>
{errors.category}
</p>
) : null}
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>場所</span>
<input
type="text"
value={location}
onChange={(event) => {
setLocation(event.target.value);
}}
placeholder="例)名古屋駅 徒歩5分"
style={{
padding: '12px',
border:
errors.location.length > 0
? '1px solid #b22323'
: '1px solid #ccc',
borderRadius: '8px',
}}
/>
{errors.location.length > 0 ? (
<p style={{ margin: 0, color: '#b22323', fontSize: '14px' }}>
{errors.location}
</p>
) : null}
</label>
<label style={{ display: 'grid', gap: '8px' }}>
<span>画像URL</span>
<input
type="url"
value={imageUrl}
onChange={(event) => {
setImageUrl(event.target.value);
}}
placeholder="例)https://example.com/image.jpg"
style={{
padding: '12px',
border:
errors.imageUrl.length > 0
? '1px solid #b22323'
: '1px solid #ccc',
borderRadius: '8px',
}}
/>
{errors.imageUrl.length > 0 ? (
<p style={{ margin: 0, color: '#b22323', fontSize: '14px' }}>
{errors.imageUrl}
</p>
) : null}
</label>
<button
type="submit"
style={{
padding: '12px 16px',
border: 'none',
borderRadius: '8px',
background: '#08131a',
color: '#fff',
fontWeight: 700,
cursor: 'pointer',
}}
>
投稿する
</button>
</form>
</main>
);
}
11. 画面を確認する
保存したら、開発サーバーを起動します。
npm run dev
ブラウザで開きます。
http://localhost:3000/spots/new
まず、何も入力せずに「投稿する」を押してください。
次のようなエラーが出ればOKです。
スポット名を入力してください。
説明文を入力してください。
カテゴリを選択してください。
場所を入力してください。
次に、説明文へ短い文字だけを入力してみます。
短いです
すると、次のエラーが出ます。
説明文は20文字以上で入力してください。
最後に、画像URLへ次を入れてみてください。
example.com/image.jpg
次のエラーが出ればOKです。
画像URLは http または https から始めてください。
12. エラーがあると投稿できない仕組み
大事なのは、この部分です。
const isValid = validateForm();
if (!isValid) {
return;
}
validateForm() が false を返したら、そこで処理を止めます。
つまり、エラーがある状態では投稿処理に進みません。
13. 入力ミスを直したらどうなるか
今回のコードでは、投稿ボタンを押したときに再チェックします。
たとえば、最初に未入力でエラーが出ても、
正しく入力してもう一度「投稿する」を押せば、エラーは消えます。
理由は、毎回このように新しいエラーを作り直しているからです。
const nextErrors: FormErrors = {
name: '',
description: '',
category: '',
location: '',
imageUrl: '',
};
これにより、前回のエラーが残り続けるのを防げます。
14. よくあるつまずき
1. エラーが表示されない
setErrors(nextErrors) があるか確認してください。
setErrors(nextErrors);
2. 空白だけで投稿できてしまう
trim() を使っているか確認してください。
name.trim().length === 0
3. エラーがあるのに投稿される
isValid のチェックがあるか確認してください。
if (!isValid) {
return;
}
4. 画像URLのチェックが厳しすぎる
今回は学習用なので、http:// または https:// から始まるかだけ見ています。
本格的なアプリでは、画像ファイル形式やアップロード機能も考えます。
15. 今日覚えること
今回覚えることは、次の4つです。
未入力は trim() でチェックする
文字数は length でチェックする
URL形式は正規表現でチェックする
エラーは state に保存して表示する
一番大切なのは、この流れです。
投稿ボタンを押す
↓
入力内容をチェックする
↓
エラーがあれば表示する
↓
問題がなければ投稿処理へ進む
ここまでのまとめ
この節では、フォームにバリデーションを追加しました。
できるようになったことは、次の4つです。
- 未入力チェック
- 文字数チェック
- 不正なURLチェック
- エラーメッセージ表示
バリデーションは、ユーザーを責めるためのものではありません。
正しく入力できるように助けるための仕組み です。
フォームを作るときは、必ず入れておきたい基本機能です。
練習問題
問題1
未入力チェックで、前後の空白を消すために使うものは何ですか。
問題2
文字数を調べるときに使うプロパティは何ですか。
問題3
エラーメッセージを画面に表示するために、今回どの state を使いましたか。
問題4
エラーがあるときに投稿処理を止めるコードはどれですか。
回答
問題1の回答
trim()
問題2の回答
length
問題3の回答
errors
問題4の回答
if (!isValid) {
return;
}
次に進む前のチェック
次へ進む前に、次の4つを確認してください。
- 何も入力せずに投稿するとエラーが出る
- 説明文が短いとエラーが出る
- 不正な画像URLを入れるとエラーが出る
- 正しく入力すると投稿内容を受け取れる ここまでできればOKです。
次は、投稿・編集・削除ができるミニCRUDアプリへ進みます。
