Reactでフォームの下書きをlocalStorageに保存する方法
入力中のフォームを localStorage に保存しておくと、うっかり再読み込みしたり、タブを閉じたりしても、次に開いたときに下書きを戻せます。React では、最初の表示のときに useState の初期化関数で読み込み、入力が変わるたびに useEffect で書き出す形にすると、短いコードで済みます。
筆者は検証環境で、お問い合わせフォームに下書きの保存を付け、Chromium で入力・再読み込み・送信の動きを確かめました。保存された値が壊れているとき、容量が足りないとき、サーバーで描画するときの動きも試しています。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作して確かめました。ほかのブラウザでは試していません。
読み込みは useState(loadDraft) のように、関数そのものを渡して最初の表示のときだけ行います。書き出しは useEffect で行い、フォームが空になったら下書きを消します。localStorage の読み書きは try で囲み、失敗してもフォームが止まらないようにします。
読み書きを、失敗しない関数にまとめる
MDN の localStorage のページにあるとおり、localStorage に入るキーと値は文字列です。そこで、下書きのオブジェクトは JSON.stringify で文字列にして保存します。読み込むときは、値がないとき、JSON として壊れているとき、形が今のフォームと違うときは、どれも null を返すようにしておきます。
export type Draft = { name: string; message: string };
// 形を変えたときに古い下書きを読まないよう、キーに版を入れておく
const STORAGE_KEY = 'contact-form-draft:v1';
function isDraft(value: unknown): value is Draft {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return typeof v.name === 'string' && typeof v.message === 'string';
}
// 読めない・壊れている・形が違うときは null を返す
export function loadDraft(): Draft | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const value: unknown = JSON.parse(raw);
return isDraft(value) ? value : null;
} catch {
return null;
}
}
// 保存できたら true。容量を超えたときなどは setItem が例外を投げる
export function saveDraft(draft: Draft): boolean {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
return true;
} catch {
return false;
}
}
export function clearDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// 消せなくても、フォームの動きは変えない
}
}
try で囲むのは、JSON.parse の失敗のためだけではありません。MDN の setItem のページによると、容量が足りないとき setItem は QuotaExceededError を投げます。MDN の localStorage のページには、利用者がブラウザの設定でデータの保存を止めているときなどに、localStorage を使おうとすると SecurityError が出る、とあります。
最初の表示で読み込み、変わるたびに書き出す
フォームの state と localStorage をつなぐ部分は、カスタムフックにまとめました。
import { useEffect, useState } from 'react';
import { clearDraft, loadDraft, saveDraft, type Draft } from './draft';
const EMPTY: Draft = { name: '', message: '' };
export function useFormDraft() {
// 関数そのものを渡すと、最初の表示のときだけ呼ばれる
const [initial] = useState(loadDraft);
const [draft, setDraft] = useState<Draft>(initial ?? EMPTY);
const [restored, setRestored] = useState(initial !== null);
const [saveFailed, setSaveFailed] = useState(false);
// draft が変わるたびに、localStorage に書き出す
useEffect(() => {
if (draft.name === '' && draft.message === '') {
clearDraft(); // 空になったら、空の下書きを残さずに消す
setSaveFailed(false);
return;
}
setSaveFailed(!saveDraft(draft));
}, [draft]);
const reset = () => {
setDraft(EMPTY);
setRestored(false);
};
return { draft, setDraft, reset, restored, saveFailed };
}
useState(loadDraft) と書くと、loadDraft は最初の表示のときにだけ呼ばれます。useState(loadDraft()) と書くと、入力のたびに再描画されるので、そのたびに localStorage を読みに行きます。この違いは、公式リファレンスの useState のページにある、初期値を作り直さないための説明に書かれています。同じページによると、開発中に StrictMode で動かすと、初期化関数は2回呼ばれます。loadDraft は読むだけなので、2回呼ばれても結果は変わりません。
書き出しを useEffect に置いたのは、localStorage が React の外にあるものだからです。公式リファレンスの、Effect で外部のシステムとつなぐ説明にあるとおり、Effect は React の外のものを state に合わせるために使います。入力欄の onChange で saveDraft を呼ぶ書き方もできますが、この形なら「下書きを消す」ボタンや送信など、draft を変える場所が増えても書き出しの処理は1か所で済みます。
フォームの側は、このフックが返す draft と setDraft を使うだけです。次のコードは、検証に使ったコンポーネントから、比較用のフックに差し替えるための引数を除いたものです。
import type { FormEvent } from 'react';
import { useFormDraft } from './useFormDraft';
export function ContactForm() {
const { draft, setDraft, reset, restored, saveFailed } = useFormDraft();
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
// ここで送信する。送信に成功したら、下書きを消す
reset();
};
return (
<form onSubmit={handleSubmit}>
{restored && <p role="status">保存されていた下書きを復元しました</p>}
{saveFailed && <p role="alert">下書きを保存できませんでした</p>}
<label>
お名前
<input
name="name"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</label>
<label>
お問い合わせ内容
<textarea
name="message"
value={draft.message}
onChange={(e) => setDraft({ ...draft, message: e.target.value })}
/>
</label>
<button type="submit">送信</button>
<button type="button" onClick={reset}>
下書きを消す
</button>
</form>
);
}
検証では送信先を用意していないので、送信ボタンでは reset() だけを呼んでいます。実際のアプリでは、送信に失敗したときに下書きまで消えないよう、成功したあとで reset() を呼びます。
入力・再読み込み・送信で、下書きがどう動くか
検証環境の Chromium で、入力してから再読み込みし、そのあと送信してもう一度再読み込みしました。次の画面で、フォームの下にある枠は、そのときに localStorage に入っている値です。この枠と、上の再読み込みボタンや手順の表示は、動きを見せるために検証用のページに足したものです。

検証の出力から抜き出すと、次のとおりです。再読み込みのあとも入力欄に値が戻り、送信したあとは localStorage の値が null(キーがない状態)になりました。送信のあとに再読み込みしても、フォームは空のままで、復元の表示も出ませんでした。
入力したあと: {"name":"山田","message":"注文した本が届きません","status":null,"alert":null,"stored":"{\"name\":\"山田\",\"message\":\"注文した本が届きません\"}","setItemCalls":2}
再読み込みのあと: {"name":"山田","message":"注文した本が届きません","status":"保存されていた下書きを復元しました","alert":null,"stored":"{\"name\":\"山田\",\"message\":\"注文した本が届きません\"}","setItemCalls":2}
送信したあと: {"name":"","message":"","status":null,"alert":null,"stored":null,"setItemCalls":2}
再読み込みのあと: {"name":"","message":"","status":null,"alert":null,"stored":null,"setItemCalls":0}
setItemCalls は、検証用に数えた setItem の呼び出し回数です。再読み込みでページを開き直すと、0 から数え直します。下書きを復元したあとに 2 になっているのは、開いた直後の Effect が下書きを書き出しているためです。検証は開発用のビルドを StrictMode で動かしていて、公式リファレンスの useEffect の注意書きにあるとおり、開発中は最初に Effect がもう1回多く動きます。同じ値を書き直すだけなので、下書きは変わりません。
空になったら、保存せずに消す
useFormDraft では、フォームが空になったときに clearDraft() を呼んでいます。この分岐を入れずに、空のときもそのまま書き出す書き方と比べました。
送信したあと: {"name":"","message":"","status":null,"alert":null,"stored":"{\"name\":\"\",\"message\":\"\"}","setItemCalls":4}
再読み込みのあと: {"name":"","message":"","status":"保存されていた下書きを復元しました","alert":null,"stored":"{\"name\":\"\",\"message\":\"\"}","setItemCalls":2}
送信で state を空に戻すと、Effect がその空の値を書き出すので、localStorage には空の下書きが残りました。次に開いたときは、中身が空なのに「復元しました」と表示されます。送信したあとは、空の値を書くのではなく、キーごと消しておきます。
壊れた値や容量不足でも、フォームを止めない
localStorage に残っている値が、今のフォームの形で保存されたものとは限りません。前の版のアプリが別の形で保存した値や、途中で壊れた値が残っていることも考えられます。保存されている値を3通りに書き換えてから、ページを開き直しました。
保存されていた値 {broken: {"name":"","message":"","status":null,"alert":null,"stored":null,"setItemCalls":0}
保存されていた値 {"name":1,"message":"x"}: {"name":"","message":"","status":null,"alert":null,"stored":null,"setItemCalls":0}
保存されていた値 null: {"name":"","message":"","status":null,"alert":null,"stored":null,"setItemCalls":0}
入力したあと: {"name":"佐藤","message":"","status":null,"alert":null,"stored":"{\"name\":\"佐藤\",\"message\":\"\"}","setItemCalls":1}
JSON として読めない値、name が文字列でない値、null のどれでも、フォームは空の状態で開き、復元の表示は出ませんでした。開いたときに Effect がフォームを空と判断して clearDraft() を呼ぶので、壊れた値はそこで消えます。そのあと入力すると、正しい形で保存されました。ブラウザのコンソールには、エラーも警告も出ていません。
容量のほうは、別のキーに大きな文字列を書き込んで、localStorage をいっぱいにしてから試しました。
容量を使い切るまで書いた結果: {"error":"QuotaExceededError","keys":18,"chars":5242872}
入力したあと: {"name":"山田","message":"","status":null,"alert":"下書きを保存できませんでした","stored":null,"setItemCalls":1}
ほかのキーを消して入力したあと: {"name":"山田太郎","message":"","status":null,"alert":null,"stored":"{\"name\":\"山田太郎\",\"message\":\"\"}","setItemCalls":2}
Chromium 141 では、キーと値の文字数の合計が 5,242,872 文字のところで QuotaExceededError が出て、それ以上は書き込めませんでした。MDN のストレージの容量の説明では、localStorage に保存できるのはオリジンごとに 5 MiB までとされています。測った文字数は 5×1024×1024=5,242,880 に近い値でしたが、文字数と MiB がどう対応するかは確かめていません。この状態で入力すると、saveDraft が false を返し、フォームには「下書きを保存できませんでした」と出ました。入力そのものは続けられ、ほかのキーを消したあとは、また保存できるようになりました。
保存を間引くと、直前の入力が消えることがある
入力のたびに書き出すのが気になるときは、入力が止まってから書き出す(デバウンスする)書き方も考えられます。比べるために、Effect を次のように書き換えたフックを用意しました。
useEffect(() => {
const id = setTimeout(() => {
setSaveFailed(!saveDraft(draft));
}, 500);
return () => clearTimeout(id);
}, [draft]);
名前の欄に Yamada と1文字ずつ入力し、そのまますぐ再読み込みしました。検証の出力から、すぐに再読み込みした部分を抜き出すと次のとおりです。
記事の useFormDraft: 入力したあとの setItem の回数 6、保存されていた値 {"name":"Yamada","message":""}
記事の useFormDraft: 再読み込みのあとの入力欄 "Yamada"
500ミリ秒待ってから書き出す書き方: 入力したあとの setItem の回数 0、保存されていた値 null
500ミリ秒待ってから書き出す書き方: 再読み込みのあとの入力欄 ""
入力のたびに書き出す useFormDraft では、6文字で setItem が6回呼ばれ、再読み込みのあとも Yamada が戻りました。500ミリ秒待つ書き方では、まだ1回も書き出していないうちにページを開き直したので、入力した6文字はすべて消えました。再読み込みのあとに4文字入力し、1秒待ってから数えたところ、待つ書き方の setItem は4文字の入力で1回だけでした。
間引けば書き出しの回数は減りますが、待っている間にページを閉じた分は残りません。この検証の下書きは短い文字列なので、筆者は入力のたびに書き出す形にしました。
サーバーで描画するときは、use(browser()) で包む
ここまでは、ブラウザだけで描画する場合の話です。サーバーで HTML を作る場合は、そのままでは困ることがあります。検証環境の Node.js 22 では localStorage が定義されていません(typeof localStorage は undefined)。そのため、サーバーで描画すると下書きを読めず、空のフォームの HTML ができました。ブラウザでは保存された下書きを読むので、最初の表示が食い違います。
renderToString で作った HTML を hydrateRoot で動かしたところ、下書きは表示されたものの、hydrateRoot の onRecoverableError に、次のエラーが届きました(検証では、エラーの文章のうち1行目だけを記録しています)。
onRecoverableError: Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
React 19.3 には、こういうコンポーネントをブラウザでだけ描画するための browser が react-dom に入りました。公式リファレンスの browser のページの例も、localStorage に保存した下書きを読むものです。同じページによると、use(browser()) はサーバーでの描画を止めて、いちばん近い <Suspense> の fallback を代わりに出し、ブラウザではそのまま描画を続けます。
import { Suspense, use } from 'react';
import { browser } from 'react-dom';
import { ContactForm } from './ContactForm';
// localStorage を読むフォームを、ブラウザでだけ描画する
function BrowserOnlyContactForm() {
use(browser('下書きは localStorage に保存されている'));
return <ContactForm />;
}
これを <Suspense fallback={<p>下書きを読み込んでいます</p>}> で包んでサーバーで描画すると、HTML にはフォームの代わりに「下書きを読み込んでいます」が入りました。ブラウザで動かすと下書きが戻り、onRecoverableError は呼ばれず、コンソールにもエラーと警告は出ませんでした。公式リファレンスの注意書きにあるとおり、サーバーで描画するときは <Suspense> の中に置く必要があります。browser に渡した文字列は省略でき、サーバーでの描画を止めたことを知らせる onBrowserBailout に渡されるエラーの cause になります。
検証は renderToString と hydrateRoot を直接使ったもので、Next.js などのフレームワークの上では試していません。
下書きに入れてよいもの
localStorage に入れた値は、消すまで残ります。MDN の localStorage のページによると、localStorage には有効期限がなく、sessionStorage はページのセッションが終わると消えます。MDN の sessionStorage のページによると、タブやウィンドウを閉じるとセッションが終わります。OWASP の HTML5 セキュリティのチートシートも、ずっと残す必要がなければ sessionStorage を使うよう勧めています。タブを閉じたら消えてよい下書きなら、sessionStorage に替える手もあります。どちらも getItem と setItem で読み書きできますが、この記事では sessionStorage での動きは試していません。
パスワード、カード番号、本人確認の情報のような値は、下書きとして保存しないでください。同じチートシートは、1つのクロスサイトスクリプティングで、localStorage や sessionStorage に入っているデータはすべて盗まれうるとして、機密の情報を保存しないよう勧めています。sessionStorage に替えても、この点は同じです。
以前の記事「ReactのdangerouslySetInnerHTMLが危ない理由」の検証でも、ページに入り込んだスクリプトから localStorage の値を読み出せました。保存するのは、消えても困らず、ほかの人に読まれても困らない入力にとどめます。





