JavaScript実践 #4 フォームの扱い

#3 fetch と非同期 UI でデータを受け取って画面に反映するパターンを見ました。今回は逆方向 — ユーザー入力を受け取って検証してサーバーに送る場面を扱います。

HTML のビルトイン検証から #

JavaScript で検証を直接書く前に、HTML5 のビルトイン検証から活用してください。

HTML ビルトイン検証
<form id="signup">
  <input name="email" type="email" required>
  <input name="password" type="password" minlength="8" required>
  <input name="age" type="number" min="14" max="120">
  <input name="username" pattern="[a-z0-9_]{3,20}" required>
  <button type="submit">登録</button>
</form>

これだけで — 空のフィールドを防ぐ、メール形式チェック、長さ / 範囲チェック、正規表現パターンまですべて動作します。フォーム送信時にブラウザが自動で検査して、最初の invalid フィールドにエラー吹き出しまで表示してくれます。

ビルトインで全部できるのに JavaScript で再実装するのは、たいてい無駄です。ビルトインを基本に置いて、JavaScript は補強として入れます。

ビルトイン検証の活用 — Constraint Validation API #

JavaScript からビルトイン検証を活用するための道具。

ValidityState
const input = document.querySelector('input[name="email"]');

input.validity.valid;          // すべて通過?
input.validity.valueMissing;    // required 違反
input.validity.typeMismatch;    // type=email 形式違反
input.validity.tooShort;        // minlength 違反
input.validity.patternMismatch; // pattern 違反
// ... など

input.validationMessage;        // ブラウザが表示するメッセージ

フォーム単位で:

form 単位
form.checkValidity();    // フォーム全体が valid か
form.reportValidity();   // 検査して invalid なら吹き出しを表示

この API を使えば、ビルトイン検証をそのまま活用しながら — 自分たちの UI メッセージを表示できます。

カスタムエラーメッセージ #

デフォルトメッセージが不自然な(英語 / 短すぎる / 翻訳が足りない)ときは、自分たちで指定できます。

setCustomValidity
input.addEventListener('input', () => {
  if (input.value && !/^[a-z]+$/.test(input.value)) {
    input.setCustomValidity('小文字のみ可能です');
  } else {
    input.setCustomValidity('');   // 空にすれば valid と見なされる
  }
});

setCustomValidity('空文字列ではないメッセージ') があると入力は invalid と見なされます。検査を解除するには空文字列で再設定。

検証 + 表示 — 自分で行うパターン #

ビルトインの吹き出しが気に入らないなら、自分たちで出力しましょう。よく使う形:

HTML
<form id="signup" novalidate>
  <label>
    メール
    <input name="email" type="email" required>
    <span class="error" data-for="email"></span>
  </label>

  <label>
    パスワード
    <input name="password" type="password" minlength="8" required>
    <span class="error" data-for="password"></span>
  </label>

  <button type="submit">登録</button>
</form>

novalidate 属性がポイント — ブラウザの自動検証をオフにします。すべて自分たちで処理します。

検証 + 表示
function showError(name, message) {
  form.querySelector(`[data-for="${name}"]`).textContent = message;
}

function clearErrors() {
  form.querySelectorAll('.error').forEach((el) => (el.textContent = ''));
}

form.addEventListener('submit', (e) => {
  e.preventDefault();
  clearErrors();

  let hasError = false;
  for (const field of form.elements) {
    if (!field.name) continue;
    if (!field.checkValidity()) {
      showError(field.name, field.validationMessage);
      hasError = true;
    }
  }

  if (hasError) return;

  const formData = new FormData(form);
  submit(Object.fromEntries(formData));
});

ビルトイン検証を JavaScript が活用しつつ、メッセージは自分たちのマークアップに出力します。この方式が最もよく使われ、堅牢です。

FormData — フォーム入力を一度に集める #

React シリーズ で見た道具がバニラでも同じように動作します。

FormData
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  if (!form.checkValidity()) return form.reportValidity();

  const formData = new FormData(form);

  // 単一フィールド
  formData.get('email');           // 'me@example.com'

  // すべてのキーと値
  for (const [key, value] of formData) {
    console.log(key, value);
  }

  // オブジェクトに変換
  const data = Object.fromEntries(formData);
  // { email: '...', password: '...', ... }

  await fetch('/api/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
});

new FormData(form) にフォームをまるごと渡すと、すべての入力を自動で収集します。name 属性のあるフィールドだけが含まれます。

FormData をそのまま送る — multipart #

JSON に変換せず FormData をそのまま fetch のボディとして送ることもできます。ファイルアップロードがあるときはこちらが自然です。

multipart ボディ
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  await fetch('/api/upload', {
    method: 'POST',
    body: new FormData(form),
    // Content-Type を直接書かないこと — ブラウザが boundary を含めて自動設定
  });
});

中級 #6 fetch API で見たパターンです。

チェックボックス / 複数選択の扱い #

複数値の入力
<input type="checkbox" name="tags" value="js">
<input type="checkbox" name="tags" value="ts">
<input type="checkbox" name="tags" value="react">

<select name="category" multiple>
  <option value="news">ニュース</option>
  <option value="tip">ヒント</option>
</select>
複数値を受け取る
const formData = new FormData(form);

formData.get('tags');         // 最初の値だけ ('js')
formData.getAll('tags');      // ['js', 'ts'] — 選択されたすべて
formData.getAll('category');  // 複数選択のすべての値

get は最初の値、getAll は配列ですべて。チェックボックスグループは常に getAll を使うほうが安全です。

Object.fromEntries(formData) は同じキーに複数の値があると最後だけが残るので、複数値があるフォームでは直接処理する必要があります。

複数値をオブジェクトに変換
const data = {};
for (const key of new Set(formData.keys())) {
  const all = formData.getAll(key);
  data[key] = all.length > 1 ? all : all[0];
}

よく使う検証パターン #

1) パスワード一致確認 #

ビルトイン検証では拾えない場面です。

パスワード一致
const password = form.elements.password;
const confirm = form.elements.passwordConfirm;

function syncPasswordMatch() {
  if (password.value !== confirm.value) {
    confirm.setCustomValidity('パスワードが一致しません');
  } else {
    confirm.setCustomValidity('');
  }
}

password.addEventListener('input', syncPasswordMatch);
confirm.addEventListener('input', syncPasswordMatch);

2) 非同期検証 — ユーザー名重複チェック #

サーバーに問い合わせないと分からない検証。

非同期検証
const username = form.elements.username;

const checkUsername = debounce(async (value) => {
  if (!value) return;

  const res = await fetch(`/api/check-username?u=${value}`);
  const { available } = await res.json();

  username.setCustomValidity(available ? '' : 'すでに使用中の名前です');
}, 400);

username.addEventListener('input', (e) => checkUsername(e.target.value));

#3 fetch と非同期 UI のデバウンスパターンがそのまま入ります。

送信中 — 重複送信防止 #

送信中 disabled
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  if (!form.checkValidity()) return form.reportValidity();

  const submitBtn = form.querySelector('button[type="submit"]');
  submitBtn.disabled = true;

  try {
    const data = Object.fromEntries(new FormData(form));
    await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    showSuccess('登録完了');
    form.reset();
  } catch (err) {
    showError(err.message);
  } finally {
    submitBtn.disabled = false;
  }
});

finally がポイント — 成功 / 失敗に関わらずボタンを再度有効化します。ユーザーが同じフォームを 2 回送信する事故を防ぎます。

フォームのリセットと初期値 #

フォーム道具
form.reset();              // すべてのフィールドを初期値に
input.defaultValue;        // 初期 value (HTML に書かれた値)
input.value;                // 現在の値

reset<input value="..."> のように HTML に書かれた初期値に戻します。JavaScript で動的に埋めた値は初期値ではありません — 明示的に空にしたいなら input.value = ''

change vs input — どのイベントを聞くか #

イベントの違い
input    キー入力ごと / 変化ごと — リアルタイム
change   フォーカスを失ったとき / select 変更時 — 変更完了

リアルタイム検証は input。「入力が終わったとき」の重い作業は change またはデバウンスされた input

blur 時点の検証 #

blur で検証
input.addEventListener('blur', () => {
  if (!input.checkValidity()) {
    input.classList.add('invalid');
    showError(input.name, input.validationMessage);
  } else {
    input.classList.remove('invalid');
    showError(input.name, '');
  }
});

タイピング中は赤線を出さず、フォーカスが外れたときだけ検査するパターン。ユーザーにとって目障りになりにくいです。

アクセシビリティ — フォームは特に重要 #

アクセシビリティの良いフォーム
<label for="email">メール</label>
<input id="email" name="email" type="email" required
       aria-describedby="email-error">
<span id="email-error" class="error" role="alert"></span>

ポイントは三つ:

  1. <label for="..."> または <label> の中に input を入れる
  2. aria-describedby でエラー領域と紐づける
  3. エラー領域に role="alert" または aria-live="polite" を付けて、変更時にスクリーンリーダーが読むようにする

ビルトインメッセージは自動でアクセシビリティを持っていますが、自分たちで出力するエラーは上のセットアップが必要です。

まとめ #

この記事で整理した内容:

  • HTML ビルトイン検証から活用 (requiredtypepatternmin/max)
  • ValidityState API で JavaScript から活用
  • setCustomValidity でカスタムメッセージ
  • novalidate + 直接処理パターンが最も一般的
  • FormData でフォーム入力を一度に収集
  • ファイルを含む場合 FormData をそのまま送る (Content-Type 自動)
  • getAll で複数値(チェックボックスグループ、multi select)
  • パスワード一致、非同期ユーザー名検証のような補強パターン
  • 送信中の disabled + finally で復元
  • アクセシビリティ — labelaria-describedbyrole="alert"

次の記事(#5 ローカルストレージと軽量な状態管理)ではデータをブラウザに保存する道具と、ライブラリなしで画面の状態をすっきり管理するパターンを扱います。

X