JavaScript実践 #4 フォームの扱い
#3 fetch と非同期 UI でデータを受け取って画面に反映するパターンを見ました。今回は逆方向 — ユーザー入力を受け取って検証してサーバーに送る場面を扱います。
HTML のビルトイン検証から #
JavaScript で検証を直接書く前に、HTML5 のビルトイン検証から活用してください。
<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 からビルトイン検証を活用するための道具。
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.checkValidity(); // フォーム全体が valid か
form.reportValidity(); // 検査して invalid なら吹き出しを表示
この API を使えば、ビルトイン検証をそのまま活用しながら — 自分たちの UI メッセージを表示できます。
カスタムエラーメッセージ #
デフォルトメッセージが不自然な(英語 / 短すぎる / 翻訳が足りない)ときは、自分たちで指定できます。
input.addEventListener('input', () => {
if (input.value && !/^[a-z]+$/.test(input.value)) {
input.setCustomValidity('小文字のみ可能です');
} else {
input.setCustomValidity(''); // 空にすれば valid と見なされる
}
});setCustomValidity('空文字列ではないメッセージ') があると入力は invalid と見なされます。検査を解除するには空文字列で再設定。
検証 + 表示 — 自分で行うパターン #
ビルトインの吹き出しが気に入らないなら、自分たちで出力しましょう。よく使う形:
<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 シリーズ で見た道具がバニラでも同じように動作します。
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 のボディとして送ることもできます。ファイルアップロードがあるときはこちらが自然です。
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 のデバウンスパターンがそのまま入ります。
送信中 — 重複送信防止 #
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 時点の検証 #
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>ポイントは三つ:
<label for="...">または<label>の中に input を入れるaria-describedbyでエラー領域と紐づける- エラー領域に
role="alert"またはaria-live="polite"を付けて、変更時にスクリーンリーダーが読むようにする
ビルトインメッセージは自動でアクセシビリティを持っていますが、自分たちで出力するエラーは上のセットアップが必要です。
まとめ #
この記事で整理した内容:
- HTML ビルトイン検証から活用 (
required、type、pattern、min/max) - ValidityState API で JavaScript から活用
setCustomValidityでカスタムメッセージnovalidate+ 直接処理パターンが最も一般的FormDataでフォーム入力を一度に収集- ファイルを含む場合 FormData をそのまま送る (Content-Type 自動)
getAllで複数値(チェックボックスグループ、multi select)- パスワード一致、非同期ユーザー名検証のような補強パターン
- 送信中の
disabled+finallyで復元 - アクセシビリティ —
label、aria-describedby、role="alert"
次の記事(#5 ローカルストレージと軽量な状態管理)ではデータをブラウザに保存する道具と、ライブラリなしで画面の状態をすっきり管理するパターンを扱います。