「入力するたびに文字数が更新される」「フォームを編集中に離脱しようとすると確認が出る」「名前を入力するとリアルタイムでプレビューに反映される」——こうした UX は、フォームの変化を JavaScript でリアルタイムに監視することで実現します。
この記事では、監視に使うイベントの使い分けから、文字数カウント・ダーティ状態管理・デバウンスを使った API 連携・フォーム全体の一括監視まで体系的に解説します。
監視に使うイベントの使い分け
フォーム入力の監視には主に3つのイベントを使います。目的に合わせて使い分けることが重要です。
| イベント | 発火タイミング | 適した用途 |
|---|---|---|
input |
値が変わるたびにリアルタイムで発火 | 文字数カウント・ライブプレビュー・リアルタイム監視 |
change |
フォーカスが外れて値が変わったとき | セレクト・チェックボックス・ラジオボタンの変化検知 |
keydown |
キーを押した瞬間 | 特定キー(Enter・Escape)の検知、入力を止める処理 |
const textInput = document.getElementById('textInput');
const selectEl = document.getElementById('selectEl');
// input: テキスト入力は1文字ごとにリアルタイムで発火
textInput.addEventListener('input', (e) => {
console.log('入力中:', e.target.value); // 文字を打つたびに実行
});
// change: テキスト入力はフォーカスが外れて値が変わったときだけ発火
textInput.addEventListener('change', (e) => {
console.log('確定:', e.target.value);
});
// セレクトボックスは input でも change でも検知できるが change が一般的
selectEl.addEventListener('change', (e) => {
console.log('選択:', e.target.value);
});
input イベントを使います。change はフォーカスが外れたときにしか発火しないため、1文字ごとの即時反映には使えません。セレクトボックス・チェックボックス・ラジオボタンは選択操作と同時に値が確定するため、change または input どちらでも動作します。文字数カウンターと入力上限の表示
SNS 投稿フォームなどでよく見る「残り N 文字」表示の実装です。
<div class="textarea-wrapper">
<textarea
id="tweetText"
maxlength="140"
placeholder="テキストを入力してください(最大140文字)"
rows="4"
></textarea>
<div class="char-counter">
<span id="charCount">0</span> / <span id="charLimit">140</span>
</div>
</div>
.textarea-wrapper {
position: relative;
}
.char-counter {
text-align: right;
font-size: 0.85rem;
color: #64748b;
margin-top: 4px;
transition: color 0.2s;
}
.char-counter.near-limit {
color: #f59e0b; /* 残り少なくなったら黄色 */
}
.char-counter.over-limit {
color: #e11d48; /* 超えたら赤(maxlength があれば実際には超えない) */
}
const textarea = document.getElementById('tweetText');
const countEl = document.getElementById('charCount');
const counter = document.querySelector('.char-counter');
const MAX_LEN = parseInt(document.getElementById('charLimit').textContent, 10);
textarea.addEventListener('input', () => {
const len = textarea.value.length;
countEl.textContent = len;
// 残り文字数に応じてスタイルを変更
counter.classList.toggle('near-limit', len >= MAX_LEN * 0.8 && len < MAX_LEN);
counter.classList.toggle('over-limit', len >= MAX_LEN);
});
maxlength 属性を設定すると、ブラウザが入力を自動的に制限します。JavaScript でカウンターを表示する場合も maxlength をセットで使うと、超過入力を防ぎつつ残り文字数を視覚的に伝えられます。maxlength のみでは残り文字数の表示ができないため両方使いましょう。ライブプレビュー(入力内容をリアルタイムに反映)
テキストエリアに入力した内容をプレビュー欄に即時反映するパターンです。プロフィール編集・コメント投稿など幅広いUIに応用できます。
<div class="profile-editor">
<div class="field-group">
<label for="displayName">表示名</label>
<input type="text" id="displayName" placeholder="あなたの名前" maxlength="30">
</div>
<div class="field-group">
<label for="bio">自己紹介</label>
<textarea id="bio" rows="3" maxlength="160" placeholder="自己紹介を入力"></textarea>
</div>
</div>
<div class="profile-preview">
<h3 id="previewName">(未設定)</h3>
<p id="previewBio" class="pre-wrap">(未設定)</p>
</div>
const nameInput = document.getElementById('displayName');
const bioInput = document.getElementById('bio');
const previewName = document.getElementById('previewName');
const previewBio = document.getElementById('previewBio');
nameInput.addEventListener('input', () => {
// textContent を使うことで XSS を防ぐ
previewName.textContent = nameInput.value.trim() || '(未設定)';
});
bioInput.addEventListener('input', () => {
previewBio.textContent = bioInput.value.trim() || '(未設定)';
// CSS の white-space: pre-wrap で改行が保持される
});
ダーティ状態管理(未保存変更の検出)
「編集中に画面を離れようとしたら確認ダイアログを出す」UX の実装です。フォームの初期値と現在値を比較してダーティ(未保存の変更あり)状態を検出します。
const form = document.getElementById('editForm');
let initialData = null; // フォームの初期値を保存
// フォームの現在値を FormData でシリアライズして文字列化
function serializeForm(f) {
return new URLSearchParams(new FormData(f)).toString();
}
// ページ読み込み時に初期値を記録
window.addEventListener('load', () => {
initialData = serializeForm(form);
});
function isDirty() {
return serializeForm(form) !== initialData;
}
// ページ離脱時に未保存の変更があれば確認
window.addEventListener('beforeunload', (e) => {
if (isDirty()) {
e.preventDefault();
// モダンブラウザでは returnValue への代入が必要
e.returnValue = ''; // ブラウザ標準のダイアログが表示される
}
});
// フォーム送信後はダーティフラグをリセット
form.addEventListener('submit', () => {
initialData = serializeForm(form); // 保存後の値を新しい初期値に
});
e.returnValue = "保存されていない変更があります" のように独自のメッセージを設定できましたが、現在は主要ブラウザすべてでブラウザ標準のメッセージに置き換えられます。e.returnValue = ""(空文字)を設定するだけでダイアログが表示されます。特定フィールドの変化に応じてUIを更新する
「都道府県が変わったら市区町村を再取得する」「オプションを選んだら価格をリアルタイム更新する」パターンです。
const planSelect = document.getElementById('plan');
const optionCheck = document.getElementById('extraOption');
const priceEl = document.getElementById('totalPrice');
const PLANS = {
basic: { label: 'ベーシック', price: 980 },
standard: { label: 'スタンダード', price: 1980 },
premium: { label: 'プレミアム', price: 2980 },
};
const OPTION_PRICE = 500;
function updatePrice() {
const plan = PLANS[planSelect.value] ?? PLANS.basic;
const extra = optionCheck.checked ? OPTION_PRICE : 0;
const total = plan.price + extra;
priceEl.textContent = `¥${total.toLocaleString()}/月`;
}
planSelect.addEventListener('change', updatePrice);
optionCheck.addEventListener('change', updatePrice);
updatePrice(); // 初期表示
デバウンスを使ったリアルタイムAPIサジェスト
検索フォームで入力のたびにAPIを叩くと無駄なリクエストが大量に発生します。デバウンス(入力が止まってから N ms 後に実行)で必要最小限のリクエストに抑えます。
const searchInput = document.getElementById('searchInput');
const suggestionBox = document.getElementById('suggestions');
let debounceTimer = null;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const query = searchInput.value.trim();
if (!query) {
suggestionBox.innerHTML = '';
return;
}
// 300ms 入力が止まってから API を叩く
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/suggest?q=${encodeURIComponent(query)}`);
const data = await res.json();
renderSuggestions(data.items);
} catch (err) {
console.error('サジェスト取得エラー:', err);
}
}, 300);
});
function renderSuggestions(items) {
suggestionBox.innerHTML = '';
if (!items.length) return;
const ul = document.createElement('ul');
items.forEach((item) => {
const li = document.createElement('li');
li.textContent = item.label; // textContent で XSS 防止
li.addEventListener('click', () => {
searchInput.value = item.label;
suggestionBox.innerHTML = '';
});
ul.appendChild(li);
});
suggestionBox.appendChild(ul);
}
encodeURIComponent() でエスケープします。スペース・特殊文字・日本語が正しくエンコードされ、不正なリクエストを防げます。フォーム全体の変化を一括監視する
フィールドごとにイベントを設定せず、フォーム要素への イベント委譲でまとめて監視します。
const form = document.getElementById('monitoredForm');
// input・change を form 要素でまとめてキャッチ(バブリングを利用)
function onFormChange(e) {
const field = e.target;
// フォーム要素以外は無視
if (!field.name) return;
console.log(`[${field.name}] 変化:`, field.type === 'checkbox' ? field.checked : field.value);
// 変化したフィールドに応じた処理
if (field.name === 'plan') updatePrice();
if (field.name === 'bio') updateCharCount();
}
form.addEventListener('input', onFormChange);
form.addEventListener('change', onFormChange);
// 現在のフォーム全体の値を Object として取得
function getFormValues(f) {
const data = {};
new FormData(f).forEach((value, key) => {
// 同名フィールド(複数チェックボックスなど)は配列にまとめる
if (data[key] !== undefined) {
data[key] = [].concat(data[key], value);
} else {
data[key] = value;
}
});
return data;
}
new FormData(form) はチェックされていないチェックボックスの値を含みません。「オフ状態」も含めて全フィールドの状態を取得したい場合は、querySelectorAll で全チェックボックスを取得して手動で値を確認してください。IME 変換中(日本語入力中)の input イベント対策
日本語入力(IME)変換中も input イベントが発火するため、変換確定前に API を叩いたり文字数を誤カウントしたりするケースがあります。
let isComposing = false;
const input = document.getElementById('japaneseInput');
// IME 変換開始
input.addEventListener('compositionstart', () => {
isComposing = true;
});
// IME 変換確定
input.addEventListener('compositionend', () => {
isComposing = false;
handleInput(); // 変換確定後に処理を実行
});
input.addEventListener('input', () => {
if (isComposing) return; // 変換中はスキップ
handleInput();
});
function handleInput() {
console.log('確定された値:', input.value);
}
よくある質問
input イベントを推奨します。keyup は物理キーの押下に反応しますが、マウスの貼り付け・ドラッグ&ドロップ・ブラウザの自動補完による変更を検知できません。input はこれらすべてのケースで発火するため、より確実に変化を検知できます。beforeunload はブラウザタブを閉じる・別 URL に遷移するときにのみ発火します。SPA 内のルート遷移(React Router の <Link> など)は JavaScript でのページ内遷移のため発火しません。SPA で同様の UX を実現するには、ルーターの beforeRouteLeave(Vue Router)やuseBlocker(React Router v6)などのフック機能を使います。form.addEventListener("input", handler))を使うと、後から追加されたフィールドにも自動で対応できます。フォームの外に追加される場合は document.addEventListener で全体を監視し、e.target.closest("form") で対象フォームのフィールドかどうかを判定します。initialData = serializeForm(form) を呼んでください。window.load より後に値がセットされる場合は、値のセット処理が終わったタイミングで初期化するのが正確です。input/change イベントにデバウンス(500ms程度)を組み合わせて、localStorage.setItem("draft", serializeForm(form)) で保存します。ページ読み込み時に localStorage.getItem("draft") を読み取り、フィールドに復元します。ただし機密情報(パスワードなど)は localStorage に保存しないでください。まとめ
フォーム入力のリアルタイム監視のポイントをまとめます。
- テキスト入力の監視は
inputイベント(keyupより確実) - セレクト・チェックボックスの監視は
changeイベント - フォーム全体の一括監視はフォーム要素へのイベント委譲が効率的
- ダーティ状態は
FormDataで初期値と比較して検出する - API を叩くリアルタイム検索はデバウンス(300ms)で無駄なリクエストを削減
- 日本語入力中は
compositionstart/compositionendで処理をスキップする
フォームバリデーションの実装は【JavaScript】フォームバリデーション完全ガイド、フォーカスイベントの詳しい使い方は【JavaScript】フォーカスイベントの使い方もあわせてご覧ください。