Reactでフォームの下書きをlocalStorageに保存する方法

​​React​localStorage,Playwright,TypeScript,useEffect,useState,サーバーレンダリング,フォーム

入力中のフォームを 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 を返すようにしておきます。

ts
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 をつなぐ部分は、カスタムフックにまとめました。

ts
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 を使うだけです。次のコードは、検証に使ったコンポーネントから、比較用のフックに差し替えるための引数を除いたものです。

tsx
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(キーがない状態)になりました。送信のあとに再読み込みしても、フォームは空のままで、復元の表示も出ませんでした。

text
入力したあと: {"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() を呼んでいます。この分岐を入れずに、空のときもそのまま書き出す書き方と比べました。

text
送信したあと: {"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通りに書き換えてから、ページを開き直しました。

text
保存されていた値 {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 をいっぱいにしてから試しました。

text
容量を使い切るまで書いた結果: {"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 を次のように書き換えたフックを用意しました。

ts

useEffect(() => {
    const id = setTimeout(() => {
      setSaveFailed(!saveDraft(draft));
    }, 500);
    return () => clearTimeout(id);
  }, [draft]);

名前の欄に Yamada と1文字ずつ入力し、そのまますぐ再読み込みしました。検証の出力から、すぐに再読み込みした部分を抜き出すと次のとおりです。

text
記事の 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行目だけを記録しています)。

text
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 を代わりに出し、ブラウザではそのまま描画を続けます。

tsx
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 の値を読み出せました。保存するのは、消えても困らず、ほかの人に読まれても困らない入力にとどめます。

​Posted by 86