Next.jsによるWebアプリケーション開発概論

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

9おすすめスポット共有アプリを作りながら学ぶ 画面遷移設計・状態管理・ユーザー入力処理
Next.jsTailwind CSSアプリ開発開発Web開発

はじめに

この節では、フォームに バリデーション を追加します。

バリデーションとは、入力された内容が正しいかどうかをチェックする仕組みです。

たとえば、次のようなミスを防ぎます。

スポット名が空のまま
説明文が短すぎる
カテゴリが選ばれていない
画像URLがURLの形になっていない

これを入れると、ユーザーが入力ミスをしたときに、

「どこを直せばよいか」がすぐわかるようになります。


この節のゴール

この節が終わると、次のことができるようになります。

  • 未入力チェックができる
  • 文字数チェックができる
  • 不正な入力を防げる
  • エラーメッセージを表示できる
  • 送信前にフォーム内容を確認できる 今日の合言葉はこれです。

送信する前に、入力ミスをチェックする。


1. バリデーションとは

バリデーションとは、入力内容が正しいかどうかを確認することです。

たとえば、スポット名が空のまま投稿されたら困ります。

スポット名: 空
説明文: 空
カテゴリ: 図書館
場所: 空
画像URL: abc

このまま投稿されると、一覧画面で何のスポットかわかりません。

そこで、投稿ボタンを押したときにチェックします。

スポット名は入力されている?
説明文は短すぎない?
場所は入力されている?
画像URLは https:// から始まっている?

問題があれば、エラーメッセージを出します。


2. 今回チェックする内容

今回は、必要最低限のチェックだけを入れます。

項目チェック内容エラー例
スポット名空ではないかスポット名を入力してください
スポット名50文字以内か50文字以内で入力してください
説明文空ではないか説明文を入力してください
説明文20文字以上か20文字以上で入力してください
カテゴリ選ばれているかカテゴリを選択してください
場所空ではないか場所を入力してください
画像URLURL形式か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アプリへ進みます。

教材トップへ戻る