【JavaScript】フォーム入力をリアルタイムで監視する方法|input・change イベント・文字数カウント・ダーティ状態管理・デバウンス API 連携まで解説

「入力するたびに文字数が更新される」「フォームを編集中に離脱しようとすると確認が出る」「名前を入力するとリアルタイムでプレビューに反映される」——こうした UX は、フォームの変化を JavaScript でリアルタイムに監視することで実現します。

この記事では、監視に使うイベントの使い分けから、文字数カウント・ダーティ状態管理・デバウンスを使った API 連携・フォーム全体の一括監視まで体系的に解説します。

スポンサーリンク

監視に使うイベントの使い分け

フォーム入力の監視には主に3つのイベントを使います。目的に合わせて使い分けることが重要です。

イベント 発火タイミング 適した用途
input 値が変わるたびにリアルタイムで発火 文字数カウント・ライブプレビュー・リアルタイム監視
change フォーカスが外れて値が変わったとき セレクト・チェックボックス・ラジオボタンの変化検知
keydown キーを押した瞬間 特定キー(Enter・Escape)の検知、入力を止める処理
input と change の動作の違い
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 を使う:テキストフィールド・テキストエリアのリアルタイム監視には input イベントを使います。change はフォーカスが外れたときにしか発火しないため、1文字ごとの即時反映には使えません。セレクトボックス・チェックボックス・ラジオボタンは選択操作と同時に値が確定するため、change または input どちらでも動作します。

文字数カウンターと入力上限の表示

SNS 投稿フォームなどでよく見る「残り N 文字」表示の実装です。

HTML
<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>
CSS(文字数カウンター)
.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 属性との使い分け:HTML の maxlength 属性を設定すると、ブラウザが入力を自動的に制限します。JavaScript でカウンターを表示する場合も maxlength をセットで使うと、超過入力を防ぎつつ残り文字数を視覚的に伝えられます。maxlength のみでは残り文字数の表示ができないため両方使いましょう。

ライブプレビュー(入力内容をリアルタイムに反映)

テキストエリアに入力した内容をプレビュー欄に即時反映するパターンです。プロフィール編集・コメント投稿など幅広いUIに応用できます。

HTML(名前入力 → プレビュー)
<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 の実装です。フォームの初期値と現在値を比較してダーティ(未保存の変更あり)状態を検出します。

ダーティ状態の検出と beforeunload
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); // 保存後の値を新しい初期値に
});
beforeunload の独自メッセージは廃止:以前は 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 でクエリを安全にエスケープ:ユーザー入力を URL のクエリパラメータに含めるときは必ず encodeURIComponent() でエスケープします。スペース・特殊文字・日本語が正しくエンコードされ、不正なリクエストを防げます。

フォーム全体の変化を一括監視する

フィールドごとにイベントを設定せず、フォーム要素への イベント委譲でまとめて監視します。

フォーム全体を input/change で一括監視
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;
}
FormData の注意:unchecked のチェックボックスは含まれない:new FormData(form) はチェックされていないチェックボックスの値を含みません。「オフ状態」も含めて全フィールドの状態を取得したい場合は、querySelectorAll で全チェックボックスを取得して手動で値を確認してください。

IME 変換中(日本語入力中)の input イベント対策

日本語入力(IME)変換中も input イベントが発火するため、変換確定前に API を叩いたり文字数を誤カウントしたりするケースがあります。

compositionstart/compositionend で IME 変換中を検出
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);
}

よくある質問

Qinput イベントと keyup イベントのどちらを使うべきですか?
A現代の実装では input イベントを推奨します。keyup は物理キーの押下に反応しますが、マウスの貼り付け・ドラッグ&ドロップ・ブラウザの自動補完による変更を検知できません。input はこれらすべてのケースで発火するため、より確実に変化を検知できます。
Qbeforeunload の確認ダイアログを SPA のルート遷移にも適用できますか?
Abeforeunload はブラウザタブを閉じる・別 URL に遷移するときにのみ発火します。SPA 内のルート遷移(React Router の <Link> など)は JavaScript でのページ内遷移のため発火しません。SPA で同様の UX を実現するには、ルーターの beforeRouteLeave(Vue Router)やuseBlocker(React Router v6)などのフック機能を使います。
Q監視したいフィールドが動的に追加される場合はどうすればいいですか?
Aフォーム要素に対してイベント委譲(form.addEventListener("input", handler))を使うと、後から追加されたフィールドにも自動で対応できます。フォームの外に追加される場合は document.addEventListener で全体を監視し、e.target.closest("form") で対象フォームのフィールドかどうかを判定します。
Qフォームの初期値をサーバーから取得するとき、load イベントのタイミングで initialData を取得できますか?
Aサーバーから非同期でデータを取得してフィールドに埋め込む場合は、埋め込み完了後(Promiseのthen内など)に initialData = serializeForm(form) を呼んでください。window.load より後に値がセットされる場合は、値のセット処理が終わったタイミングで初期化するのが正確です。
Q監視したフォームの値を localStorage に自動保存するにはどうすればいいですか?
Ainput/change イベントにデバウンス(500ms程度)を組み合わせて、localStorage.setItem("draft", serializeForm(form)) で保存します。ページ読み込み時に localStorage.getItem("draft") を読み取り、フィールドに復元します。ただし機密情報(パスワードなど)は localStorage に保存しないでください。

まとめ

フォーム入力のリアルタイム監視のポイントをまとめます。

  • テキスト入力の監視は input イベント(keyupより確実)
  • セレクト・チェックボックスの監視は change イベント
  • フォーム全体の一括監視はフォーム要素へのイベント委譲が効率的
  • ダーティ状態は FormData で初期値と比較して検出する
  • API を叩くリアルタイム検索はデバウンス(300ms)で無駄なリクエストを削減
  • 日本語入力中は compositionstart/compositionend で処理をスキップする

フォームバリデーションの実装は【JavaScript】フォームバリデーション完全ガイド、フォーカスイベントの詳しい使い方は【JavaScript】フォーカスイベントの使い方もあわせてご覧ください。