下書き自動保存フォーム(Draft Autosave Form)— localStorageで入力を自動保存・復元

フォーム入力 初級

このコンポーネントについて

下書き自動保存フォームは、入力内容を localStorage へ自動的に保存し、うっかりリロード・ブラウザ再起動・セッションタイムアウトで入力を失っても復元できるようにするUIパターンです。問い合わせ・申請フォームなど、入力量が多く途中離脱で失うと痛い業務フォームで効果を発揮します。このページでは、入力を自動保存しつつ、リロード後に「勝手に復元」ではなく復元バナーでユーザーに戻すか選ばせる設計と、マイナンバーなど機密フィールドを保存対象から除外する実装を確認・コピペできます。

  • 入力の自動保存(debounce)input イベントを500msの debounce でまとめ、フォーム全体をオブジェクト化して1キーにJSON保存する。保存のたびに「下書きを保存しました HH:MM」を表示
  • 復元バナーで戻すか選ばせる — リロード後に下書きがあれば上部バナーを表示し、[復元する][破棄する]でユーザーに委ねる。自動で流し込まないため、古い下書きで最新入力を上書きする事故を防げる
  • 機密フィールドの保存除外data-no-save 属性を付けた項目(マイナンバー等)は保存対象から除外し、注記で明示する
  • 送信成功時に下書きを削除 — 正常送信できたら下書きをクリアし、送信済みなのに古い下書きが残る状態をなくす
  • localStorage不可環境の握り — プライベートブラウジング等での例外を try-catch で握り、自動保存を静かに無効化して通常フォームとして動作させる

実装のポイント・注意点

保存対象の収集は form.querySelectorAll('[name]:not([data-no-save])') で行うのがコツです。保存したくない機密項目に data-no-save 属性を付けておけば、セレクタの時点で除外できるため、保存処理の中で個別に「この項目は除く」と書く必要がなくなります。パスワード・マイナンバー・カード番号などを localStorage に平文で残すのは避けるべきなので、除外の意図がHTML側に残るこの方式は保守しやすい書き方です。

復元は自動で行わないのが重要な設計判断です。ページを開いた瞬間に前回の下書きを流し込むと、ユーザーが新規に書き直したいケースや、別のタブ・別セッションで進めた最新の入力を、古い下書きで上書きしてしまう事故が起こります。そのため「前回の下書きがあります」バナーを出し、[復元する] を押したときだけ値を戻します。[破棄する] では確認を挟まず localStorage.removeItem() で下書きを消します。

localStorage はプライベートブラウジングや無効設定で setItem / getItem が例外を投げることがあります。読み書きは必ず try-catch で囲み、失敗時は自動保存だけを静かに止めて、フォーム自体は通常どおり入力・送信できるようにしておくと安全です。JSON.parse も壊れたデータで例外を投げるため、こちらも try-catch で握ります。画面に出す文言は innerHTML ではなく textContent で設定してXSSを防ぎます。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

このデモは入力すると localStorage へ自動保存され、リロードしても内容が残ります。「ページを再読み込み」を押して試してください。

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>下書き自動保存フォーム サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>
  <div class="draft-demo">
    <!-- 復元バナー(初期は hidden。下書きがあるリロード後のみ表示) -->
    <div class="draft-banner" id="draft-banner" hidden>
      <span class="draft-banner-text" id="draft-banner-text"></span>
      <span class="draft-banner-actions">
        <button type="button" class="draft-restore-btn" id="draft-restore-btn">復元する</button>
        <button type="button" class="draft-discard-btn" id="draft-discard-btn">破棄する</button>
      </span>
    </div>

    <p class="draft-note">このフォームは入力すると自動保存され、リロードしても内容が残ります。</p>

    <form class="draft-form" id="draft-form">
      <label class="draft-field">
        <span class="draft-label">氏名<span class="draft-req">必須</span></span>
        <input type="text" name="name" required>
      </label>
      <label class="draft-field">
        <span class="draft-label">メールアドレス<span class="draft-req">必須</span></span>
        <input type="email" name="email" required>
      </label>
      <label class="draft-field">
        <span class="draft-label">電話番号</span>
        <input type="tel" name="tel" placeholder="例:03-1234-5678">
      </label>
      <label class="draft-field">
        <span class="draft-label">申請種別</span>
        <select name="category">
          <option value="">選択してください</option>
          <option value="subsidy">手当・給付金の申請</option>
          <option value="certify">各種証明書の発行</option>
          <option value="change">登録情報の変更</option>
          <option value="other">その他</option>
        </select>
      </label>
      <label class="draft-field">
        <span class="draft-label">申請内容</span>
        <textarea name="body" rows="4" placeholder="申請の詳細をご記入ください"></textarea>
      </label>

      <!-- 機密フィールド:data-no-save で保存対象から除外 -->
      <label class="draft-field">
        <span class="draft-label">マイナンバー</span>
        <input type="text" name="mynumber" inputmode="numeric" autocomplete="off" data-no-save
               placeholder="12桁の番号">
        <span class="draft-nosave-note">🔒 この項目は下書き保存されません</span>
      </label>

      <p class="draft-status" id="draft-status" aria-live="polite"></p>

      <div class="draft-controls">
        <button type="submit" class="draft-submit-btn">送信</button>
        <button type="button" class="reload-btn" onclick="location.reload()">ページを再読み込み</button>
        <button type="button" class="reset-btn" onclick="resetDemo()">リセット</button>
      </div>

      <div class="draft-result" id="draft-result" aria-live="polite"></div>
    </form>
  </div>

  <script src="./script.js"></script>
</body>
</html>
:root {
  --draft-accent: #2B7FE8;
  --draft-border: #D0D7E0;
  --draft-text: #33404F;
  --draft-muted: #5A6A7A;
  --draft-bg-subtle: #EEF4FF;
}

* { box-sizing: border-box; }

body {
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  background: #F4F6F9;
  padding: 24px;
}

.draft-demo {
  width: 100%;
  max-width: 560px;
  margin: 0 auto;
  background: #fff;
  padding: 20px;
  border-radius: 10px;
}

/* 復元バナー */
.draft-banner {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 12px 14px;
  margin-bottom: 16px;
  background: var(--draft-bg-subtle);
  border: 1px solid #BBD3F5;
  border-left: 4px solid var(--draft-accent);
  border-radius: 6px;
  font-size: 14px;
  color: var(--draft-text);
}
/* display: flex 要素に hidden を効かせるため必ず打ち消す */
.draft-banner[hidden] { display: none; }
.draft-banner-actions { display: flex; gap: 8px; }

.draft-restore-btn {
  padding: 6px 14px;
  font-size: 13px;
  color: #fff;
  background: var(--draft-accent);
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
.draft-restore-btn:hover { background: #1E6FD4; }

.draft-discard-btn {
  padding: 6px 14px;
  font-size: 13px;
  color: var(--draft-muted);
  background: #fff;
  border: 1.5px solid var(--draft-border);
  border-radius: 6px;
  cursor: pointer;
}
.draft-discard-btn:hover { background: #F4F6F9; }

.draft-note {
  margin: 0 0 16px;
  font-size: 13px;
  color: var(--draft-muted);
}

/* フォーム */
.draft-form { display: flex; flex-direction: column; gap: 14px; }
.draft-field { display: flex; flex-direction: column; gap: 4px; }
.draft-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  font-weight: 600;
  color: var(--draft-text);
}
.draft-req { font-size: 11px; font-weight: 700; color: #E5484D; }

.draft-field input,
.draft-field select,
.draft-field textarea {
  width: 100%;
  padding: 8px 10px;
  font-size: 14px;
  font-family: inherit;
  color: var(--draft-text);
  border: 1.5px solid var(--draft-border);
  border-radius: 6px;
}
.draft-field input:focus,
.draft-field select:focus,
.draft-field textarea:focus {
  outline: none;
  border-color: var(--draft-accent);
}
.draft-field textarea { resize: vertical; }
.draft-nosave-note { font-size: 12px; color: var(--draft-muted); }

/* 保存ステータス(空でも高さを確保して行のガタつきを防ぐ) */
.draft-status {
  min-height: 20px;
  margin: 2px 0 0;
  font-size: 12px;
  color: #2E7D46;
}

/* コントロール */
.draft-controls {
  margin-top: 4px;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.draft-submit-btn {
  padding: 8px 20px;
  font-size: 14px;
  font-weight: 600;
  color: #fff;
  background: var(--draft-accent);
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
.draft-submit-btn:hover { background: #1E6FD4; }

.reload-btn {
  padding: 8px 16px;
  font-size: 13px;
  color: var(--draft-accent);
  background: #fff;
  border: 1.5px solid var(--draft-accent);
  border-radius: 6px;
  cursor: pointer;
  font-family: inherit;
}
.reload-btn:hover { background: var(--draft-bg-subtle); }

.reset-btn {
  padding: 8px 16px;
  font-size: 13px;
  color: var(--draft-muted);
  background: #fff;
  border: 1.5px solid var(--draft-border);
  border-radius: 6px;
  cursor: pointer;
  font-family: inherit;
}
.reset-btn:hover { background: #F4F6F9; border-color: #9AA5B4; }

/* 送信結果 */
.draft-result {
  margin-top: 4px;
  font-size: 14px;
  color: var(--draft-text);
  white-space: pre-line;
}
.draft-result.is-shown {
  padding: 12px 14px;
  background: #F0FAF3;
  border: 1px solid #BFE6CC;
  border-radius: 6px;
}
(function () {
  'use strict';

  var STORAGE_KEY = 'form-draft-1'; // 下書きを保存する localStorage のキー
  var DEBOUNCE_MS = 500;            // 入力が止まってから保存するまでの待ち時間

  var form = document.getElementById('draft-form');
  var banner = document.getElementById('draft-banner');
  var bannerText = document.getElementById('draft-banner-text');
  var restoreBtn = document.getElementById('draft-restore-btn');
  var discardBtn = document.getElementById('draft-discard-btn');
  var statusEl = document.getElementById('draft-status');
  var resultEl = document.getElementById('draft-result');

  if (!form) return;

  var saveTimer = null;

  // 保存対象のフィールドを収集する(data-no-save が付いた機密項目は除外)
  function collectFields() {
    var data = {};
    form.querySelectorAll('[name]:not([data-no-save])').forEach(function (el) {
      data[el.name] = el.value;
    });
    return data;
  }

  // localStorage へ下書きを保存する
  function saveDraft() {
    var payload = { savedAt: new Date().toISOString(), fields: collectFields() };
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
      statusEl.textContent = '下書きを保存しました ' + formatTime(payload.savedAt);
    } catch (e) {
      // プライベートブラウジング等で localStorage が使えない場合は静かに無効化する
    }
  }

  // ISO文字列を HH:MM に整形する
  function formatTime(iso) {
    var d = new Date(iso);
    var h = ('0' + d.getHours()).slice(-2);
    var m = ('0' + d.getMinutes()).slice(-2);
    return h + ':' + m;
  }

  // 入力を debounce でまとめてから保存する
  form.addEventListener('input', function (e) {
    // 機密フィールドの入力では保存をトリガーしない
    if (e.target.hasAttribute('data-no-save')) return;
    clearTimeout(saveTimer);
    saveTimer = setTimeout(saveDraft, DEBOUNCE_MS);
  });

  // ページ読み込み時に下書きがあれば復元バナーを表示する
  function initRestoreBanner() {
    var raw;
    try {
      raw = localStorage.getItem(STORAGE_KEY);
    } catch (e) {
      return; // localStorage 不可
    }
    if (!raw) return;

    var payload;
    try {
      payload = JSON.parse(raw);
    } catch (e) {
      return; // 壊れたデータは無視する
    }
    if (!payload || !payload.fields) return;

    // 勝手に復元せず、ユーザーに戻すか選ばせる
    bannerText.textContent = '前回の下書きがあります(' + formatTime(payload.savedAt) + 'に保存)';
    banner.hidden = false;

    restoreBtn.onclick = function () {
      restoreDraft(payload.fields);
      banner.hidden = true;
    };
    discardBtn.onclick = function () {
      removeDraft(); // 確認なしで削除
      banner.hidden = true;
    };
  }

  // 保存値を各項目へ戻す
  function restoreDraft(fields) {
    Object.keys(fields).forEach(function (name) {
      var el = form.querySelector('[name="' + name + '"]');
      if (el) el.value = fields[name];
    });
  }

  // 下書きを削除する
  function removeDraft() {
    try {
      localStorage.removeItem(STORAGE_KEY);
    } catch (e) {
      // 何もしない
    }
  }

  // select の表示ラベルを取得する
  function selectedLabel(value) {
    var sel = form.querySelector('select[name="category"]');
    if (!sel) return value;
    var opt = sel.querySelector('option[value="' + value + '"]');
    return opt ? opt.textContent : value;
  }

  // 送信(デモなのでページ遷移せず結果を表示する)
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    // required 未入力ならブラウザ標準バリデーションが submit を止めるため、ここは成功時のみ通る
    var data = collectFields();
    var lines = ['送信しました(デモ)'];
    if (data.name) lines.push('氏名:' + data.name);
    if (data.email) lines.push('メール:' + data.email);
    if (data.category) lines.push('申請種別:' + selectedLabel(data.category));
    resultEl.textContent = lines.join('\n');
    resultEl.classList.add('is-shown');

    removeDraft();   // 送信できたら下書きを削除する
    form.reset();
    statusEl.textContent = '';
  });

  // リセット(表示のみ初期化。localStorage の下書きは消さない)
  window.resetDemo = function () {
    form.reset();
    statusEl.textContent = '';
    resultEl.textContent = '';
    resultEl.classList.remove('is-shown');
  };

  initRestoreBanner();
})();

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。項目の追加や保存タイミングの変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# 下書き自動保存フォーム(localStorage復元)作成依頼

## 概要
入力内容を localStorage へ自動保存し、リロード後に「復元バナー」で前回の下書きを戻せる申請フォームを実装してください。

## 要件
- 氏名(必須)・メールアドレス(必須)・電話番号・申請種別(select)・申請内容(textarea)・マイナンバーの入力欄を持つフォームを作る
- 入力イベントを500msのdebounceでまとめ、フォーム全体をオブジェクト化して localStorage の1キーにJSON保存する。保存のたびに「下書きを保存しました HH:MM」と表示する
- マイナンバー欄には data-no-save 属性を付け、保存対象から除外する(「この項目は下書き保存されません」と明記)
- ページ読み込み時に下書きがあれば、勝手に復元せず上部に「前回の下書きがあります(HH:MMに保存)」バナーと[復元する][破棄する]ボタンを表示する
- [復元する]で各項目に値を戻してバナーを閉じる。[破棄する]で確認なしに下書きを削除してバナーを閉じる
- 送信ボタンで送信が成功したら、下書きを削除してフォームをリセットし「送信しました」と表示する
- localStorage が使えない環境(プライベートブラウジング等)では try-catch で例外を握り、エラーを出さずに自動保存機能だけを無効化する
- デモ内に「ページを再読み込み」ボタンを置き、リロードしても入力が残ることを確認できるようにする

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
保存対象の収集は querySelectorAll('[name]:not([data-no-save])') で行い、機密フィールドをセレクタレベルで除外すること。
localStorage への読み書きは必ず try-catch で囲み、JSON.parse の失敗や localStorage 不可でも例外を投げないこと。
画面に表示するテキストは innerHTML を使わず textContent で設定すること。
リロード後の復元は自動で行わず、必ずユーザーがバナーで選べるようにすること(古い下書きで最新の入力を上書きする事故を防ぐため)。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。