splitforms.com
← Back to the journal

Save Form Data in localStorage (Auto-Save Draft on Reload)

Save form data in localStorage with 400ms debounce, restore drafts on reload, handle checkboxes and radios, skip sensitive fields, and clear after success.

Start free — 500 submissionsSee pricing →No card, no time limit. Pro is $5 per month.
Short answer
Save a text-only draft on input with a 400ms debounce, restore it on load, and clear it only after a successful response. Preserve repeated names with getAll(), restore checkbox and radio values with checked, and exclude sensitive fields.
Try it on your own form

Point any HTML form at one endpoint. 500 free submissions — no card, no time limit — and unlimited forms.

Create free account

An accidental reload should not wipe a long form

Someone writes a detailed enquiry, presses Back to check a detail, and returns to an empty form. A reload can lose the same work. Browsers sometimes preserve controls during navigation, but that is not a dependable draft system. Save a local draft so the person can continue where they stopped.

This draft stays in that browser on the same origin. It is not a submitted response, does not sync across devices, and can disappear if the person clears site data. Give each form a stable, unique id; on sites with accounts, scope keys to the current user and clear them at sign-out.

Skip sensitive fields before serializing

localStorage is plain text, and any script running on your page’s origin can read it, including injected scripts. Never save passwords, payment details, or private information just because a field happens to be in a form. File inputs cannot be restored by setting their value. Mark any other excluded field with data-no-save; an explicit allowlist is safer for sensitive forms.

The helpers below exclude password and file inputs, payment autocomplete tokens starting with cc-, and controls inside a data-no-save container. If one control is excluded, its entire shared name is excluded so another control cannot leak the same field into the draft.

function draftControls(form) {
  return Array.from(form.elements).filter(el =>
    el.matches("input, select, textarea") && el.name
  );
}

function excluded(el) {
  const tokens = (el.getAttribute("autocomplete") || "")
    .toLowerCase().split(/\s+/);
  return el.type === "password" || el.type === "file" ||
    tokens.some(token => token.startsWith("cc-")) ||
    Boolean(el.closest("[data-no-save]"));
}

function blockedNames(form) {
  return new Set(draftControls(form).filter(excluded).map(el => el.name));
}

function readDraft(form) {
  const controls = draftControls(form);
  const blocked = blockedNames(form);
  const formData = new FormData(form);
  for (const name of blocked) formData.delete(name);

  const data = Object.fromEntries(formData);
  for (const el of controls) {
    if (blocked.has(el.name) || el.matches(":disabled")) continue;
    const values = formData.getAll(el.name);
    // Empty arrays also record unchecked boxes and unselected radios.
    if (el.type === "checkbox" || el.type === "radio" ||
        el.multiple || values.length > 1) {
      data[el.name] = values;
    }
  }
  return data;
}

FormData in JavaScript follows HTML submission rules: unnamed and disabled controls are omitted, as are unchecked checkboxes. Object.fromEntries() keeps only the last entry for repeated names, so getAll(name) is essential for checkbox groups and multiple selects. Give checkboxes and radios explicit, distinct values within each group. Keep repeated text inputs uniquely named in this example; their positional restoration needs a separate mapping.

Restore values, checked states, and selected options

Use this helper with the initialization below. It loops over saved keys and matches actual controls without constructing a CSS selector from a field name. Checkboxes and radios need .checked set by value; assigning .value would change what they submit instead of selecting them.

function restoreDraft(form, data) {
  if (!data || typeof data !== "object" || Array.isArray(data)) return false;
  const blocked = blockedNames(form);
  let restored = false;
  for (const [name, saved] of Object.entries(data)) {
    if (blocked.has(name)) continue;
    const values = Array.isArray(saved) ? saved : [saved];
    if (!values.every(value => typeof value === "string")) continue;
    for (const el of draftControls(form).filter(el => el.name === name)) {
      if (el.matches(":disabled")) continue;
      if (el.type === "checkbox" || el.type === "radio") {
        el.checked = values.includes(el.value);
      } else if (el instanceof HTMLSelectElement && el.multiple) {
        for (const option of el.options) {
          option.selected = values.includes(option.value);
        }
      } else if (typeof saved === "string") {
        el.value = saved;
      } else {
        continue;
      }
      restored = true;
    }
  }
  return restored;
}

Validate the saved shape even though your own code wrote it: storage can contain an older format or edited values. Restoring controls programmatically does not fire input events. If the form has conditional sections, recalculate their visibility after restoration. Framework-controlled fields should be restored through framework state instead.

Vanilla JavaScript: debounce input saves by 400ms

Put the two helpers above and this initializer in the same script loaded before DOMContentLoaded, such as a deferred script. This markup demonstrates a checkbox group, a radio group, a multiple select, and a clearable restoration notice.

<form id="enquiry" action="https://splitforms.com/api/submit" method="post">
  <input type="hidden" name="access_key" value="YOUR_KEY" data-no-save />
  <p id="draft-notice" hidden>
    <span role="status">Draft restored - </span>
    <button type="button" id="clear-draft">clear</button>
  </p>
  <label>Name <input name="name" autocomplete="name" required /></label>
  <label>Message <textarea name="message" required></textarea></label>
  <fieldset>
    <legend>Topics</legend>
    <label><input type="checkbox" name="topics" value="design" /> Design</label>
    <label><input type="checkbox" name="topics" value="code" /> Code</label>
  </fieldset>
  <fieldset>
    <legend>Reply preference</legend>
    <label><input type="radio" name="reply" value="email" /> Email</label>
    <label><input type="radio" name="reply" value="phone" /> Phone</label>
  </fieldset>
  <label>Days <select name="days" multiple>
    <option value="mon">Monday</option>
    <option value="tue">Tuesday</option>
  </select></label>
  <button type="submit">Send</button>
  <p id="submit-status" role="status"></p>
</form>
document.addEventListener("DOMContentLoaded", () => {
  const form = document.getElementById("enquiry");
  const notice = document.getElementById("draft-notice");
  const status = document.getElementById("submit-status");
  const key = "draft:" + form.id;
  let timer;

  try {
    const saved = localStorage.getItem(key);
    if (saved && restoreDraft(form, JSON.parse(saved))) notice.hidden = false;
  } catch {
    // Malformed JSON or unavailable storage must not break the form.
  }

  function save() {
    timer = undefined;
    try {
      const data = readDraft(form);
      localStorage.setItem('draft:' + form.id, JSON.stringify(data));
    } catch {
      // Storage can be blocked or full; submitting must still work.
    }
  }

  function scheduleSave() {
    clearTimeout(timer);
    timer = setTimeout(save, 400);
  }
  form.addEventListener("input", scheduleSave);
  form.addEventListener("change", scheduleSave);
  window.addEventListener("pagehide", () => {
    if (timer !== undefined) {
      clearTimeout(timer);
      save(); // Best effort: flush the pending debounce before navigation.
    }
  });

  function clearDraft() {
    clearTimeout(timer);
    timer = undefined;
    try { localStorage.removeItem(key); } catch { /* Storage unavailable. */ }
    notice.hidden = true;
    form.reset();
  }
  document.getElementById("clear-draft").addEventListener("click", clearDraft);

  form.addEventListener("submit", event => {
    event.preventDefault();
    const button = form.querySelector('button[type="submit"]');
    button.disabled = true;
    status.textContent = "Sending…";
    clearTimeout(timer);
    save(); // Capture the latest text before controls are disabled.
    // Freeze editing so success cannot discard edits made during the request.
    const body = new FormData(form);
    const controls = draftControls(form).filter(el => !el.matches(":disabled"));
    controls.forEach(el => { el.disabled = true; });
    fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
    }).then(res => {
      if (!res.ok) throw new Error("Submission failed");
      clearDraft(); // Only now: the server returned a successful response.
      status.textContent = "Sent successfully.";
    }).catch(() => {
      status.textContent = "Could not send. Your fields are still here; please retry.";
    }).finally(() => {
      controls.forEach(el => { el.disabled = false; });
      button.disabled = false;
    });
  });
});

A 400ms debounce limits synchronous storage writes while someone types. The pagehide flush is best effort; abrupt browser termination can still lose recent edits. Catching storage exceptions keeps the form usable, but it means draft persistence is not guaranteed. For production, show a separate warning if saving fails.

Clear only after the submit succeeds

The submit event says an attempt started; it does not confirm success. The example removes the draft inside fetch().then() only when res.ok is true. For APIs that return application errors with HTTP 200, check the response body’s success flag as well. Cancel pending timers when clearing so an old callback cannot recreate the draft.

The handler also saves the latest fields before disabling them during the request. A failed request leaves both the draft and current values available for retry.

The explicit Clear button also removes the stored draft and resets the form to its HTML defaults. See reset a form after submit for why form.reset() can restore default values rather than empty every control.

localStorage vs sessionStorage for form drafts

BehaviorlocalStoragesessionStorage
Survives a reloadYesYes
After the tab closesPersists until cleared; no built-in expiryCleared when the tab closes
ScopeShared by tabs on the same originPer origin and per tab
Typical useResume a longer draft laterKeep a temporary draft within one tab

Replace every localStorage call with sessionStorage to use the same helpers within a tab. Neither storage option protects sensitive data. With localStorage, two tabs using the same key can overwrite each other; consider sessionStorage if independent drafts matter. Add your own timestamp and expiry policy if drafts should age out.

React: useEffect and a useDebounce hook

For controlled inputs, restore state instead of mutating the DOM. This compact example deliberately allows only name and message. It waits for restoration before rendering editable fields, and saves only after an edit so initial empty state cannot overwrite a stored draft.

"use client";
import { useEffect, useState } from "react";

function useDebounce(value, delay = 400) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  return debounced;
}

const KEY = "draft:react-enquiry";

export default function DraftFields() {
  const [draft, setDraft] = useState(null);
  const [edited, setEdited] = useState(false);
  const [restored, setRestored] = useState(false);
  const debounced = useDebounce(draft);

  useEffect(() => {
    let next = { name: "", message: "" };
    try {
      const saved = JSON.parse(localStorage.getItem(KEY) || "null");
      if (saved && typeof saved.name === "string" &&
          typeof saved.message === "string") {
        next = { name: saved.name, message: saved.message };
        setRestored(true);
      }
    } catch { /* Ignore invalid JSON or unavailable storage. */ }
    setDraft(next);
  }, []);

  useEffect(() => {
    if (!edited || !debounced || debounced !== draft) return;
    try { localStorage.setItem(KEY, JSON.stringify(debounced)); } catch {}
  }, [debounced, draft, edited]);

  if (!draft) return <p>Loading draft…</p>;
  function update(event) {
    const { name, value } = event.target;
    setEdited(true);
    setDraft(previous => ({ ...previous, [name]: value }));
  }
  function clear() {
    setEdited(false); // Prevent a pending debounce from saving again.
    try { localStorage.removeItem(KEY); } catch {}
    setDraft({ name: "", message: "" });
    setRestored(false);
  }
  return (
    <fieldset>
      <legend>Your enquiry</legend>
      {restored && <p><span role="status">Draft restored - </span>
        <button type="button" onClick={clear}>clear</button></p>}
      <label>Name <input name="name" value={draft.name} onChange={update} /></label>
      <label>Message <textarea name="message" value={draft.message} onChange={update} /></label>
    </fieldset>
  );
}

This JavaScript component renders fields to place inside your form. Lift its state into the parent when adding submission, freeze edits during the request, and call the same clear logic after a successful response. For controlled checkbox and radio groups, keep arrays or a selected value in state and pass checked; for a multiple select, pass an array to value. This short React version saves after the debounce only; add a lifecycle flush if you need the vanilla example’s navigation behavior.

Send the finished form to splitforms

A browser draft helps someone finish writing; your endpoint receives the finished submission. The vanilla example posts to https://splitforms.com/api/submit with a public access_key excluded from the draft. Send a fresh FormData(form) on submit so current fields and files reach the endpoint. Let the browser set the multipart Content-Type. Sign up free for a splitforms endpoint that stores submissions and emails you.

If you need to convert a validated text-only draft object back into FormData, append each array value separately. This preserves repeated names. The draft excludes credentials and files, so it is not a replacement for the complete submission payload:

function draftToFormData(data) {
  const formData = new FormData();
  for (const [name, saved] of Object.entries(data)) {
    const values = Array.isArray(saved) ? saved : [saved];
    for (const value of values) {
      if (typeof value === "string") formData.append(name, value);
    }
  }
  return formData;
}

FAQ

How do I keep form data after a page reload?

Listen for input, debounce saves by 400ms, and store a JSON draft under a key based on the form ID. On DOMContentLoaded, parse the draft in try/catch and restore the matching controls. Storage can be blocked or cleared, so treat this as a convenience rather than a backup.

How do I save checkbox groups and multiple select values?

Use FormData.getAll(name) to store an array for checkbox groups and multiple selects. Store an empty array when nothing is selected. Restore checkbox and radio checked states by matching each control’s value; set each select option’s selected state from the saved array.

Is localStorage safe for passwords or payment details?

No. localStorage stores plain text that any script running on the same origin can read. Skip password and file inputs, autocomplete tokens starting with cc-, and fields marked data-no-save. Use an explicit allowlist for forms that collect sensitive information.

When should I delete the saved draft?

Delete it only after the server confirms a successful submission, or when the user explicitly chooses Clear. A submit event alone does not mean delivery succeeded. Keep the draft after network failures or unsuccessful HTTP responses, and cancel pending save timers when clearing it.

Should I use localStorage or sessionStorage for a form draft?

Use localStorage when a draft should survive closing and reopening the browser. Use sessionStorage for a per-tab draft that survives reloads but is cleared when the tab closes. Neither automatically syncs a draft to another device or makes sensitive values safe to store.

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

How to Remove the Arrows From a Number Input (CSS, Tailwind, React)

Hide the number input spin buttons with ::-webkit-inner-spin-button and appearance: textfiel…

7 min readRead →
Tutorials

How to Change the Autofill Background Color of an Input (CSS, Tailwind)

Remove Chrome's yellow autofill background with input:-webkit-autofill, inset box-shadow, -w…

7 min readRead →
Tutorials

input type=number maxlength Not Working? 4 Fixes That Work

Why maxlength is ignored on input type=number, plus four fixes: min/max, oninput slice, text…

8 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewContact Forms for Every FrameworkStart here →GuideContact Form for Next.jsRead →GuideContact Form for ReactRead →GuideContact Form for VueRead →ReferenceContact form guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 free submissions — no card, no time limit.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe