Reactで、入力を自動で保存して「保存しました」と表示する方法

​​React​Playwright,TypeScript,useEffect,カスタムフック

入力が止まって1秒たったらサーバーに保存し、入力欄の下に「未保存の変更があります」「保存中…」「13:05 に保存しました」と出せるようにするカスタムフック useAutoSave を作りました。前の保存が終わるまで、次の保存のリクエストは送りません。こうしておくと、保存の最中に続きを打っても、サーバーの内容が古い文で上書きされません。

保存に失敗したときは「保存できませんでした」と出し、ボタンで保存し直せるようにしています。保存が済んでいないあいだにタブを閉じようとすると、ブラウザの確認ダイアログが出ます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Node.js で作った検証用のサーバーに保存させました。サーバーの処理にかかる時間と、失敗を返すかどうかは、テストから切り替えています。Firefox と Safari、スマートフォンのブラウザ、日本語の変換中の入力は試していません。

メモ欄に「しろたま」と打つと「未保存の変更があります」と出て、入力が止まってからの秒数が増えていき、1秒で「保存中…」、続けて「13:05 に保存しました」に変わる。続けて「のメモ」と打つと、今度は保存に失敗して「保存できませんでした」と「もう一度保存する」ボタンが出る。ボタンを押すと「保存中…」を経て「13:05 に保存しました」に戻る
入力が止まると保存され、失敗したらボタンで保存し直す様子

GIF の左上の説明、右上の操作の回数、「入力が止まってから」の秒数、カーソルと押した位置の輪は、撮影のために重ねたものです。保存の状態の表示は、変わった瞬間だけピンクにしています。2回目の保存は、検証用のサーバーが 500 を返すようにしてあります。

保存の状態を4つに分ける

useAutoSave は、保存の状態を次の4つのどれかで返します。画面に出す文は、使う側のコンポーネントで決めます。

状態 画面の表示 どんなときか
unsaved 未保存の変更があります 入力してから、まだ保存を始めていない
saving 保存中… 保存のリクエストの応答を待っている
saved 13:05 に保存しました いまの内容をサーバーに保存できた
error 保存できませんでした 保存のリクエストが失敗した

saved のときの時刻は、保存できた時刻です。ページを開いた直後は、まだ何も保存していないので何も出しません。

useAutoSave のコード

フックには、保存する値と、保存する関数と、待つ時間(ミリ秒)を渡します。

ts
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';

export type SaveStatus = 'saved' | 'unsaved' | 'saving' | 'error';

export function useAutoSave<T>(value: T, save: (value: T) => Promise<void>, delay = 1000) {
  const [status, setStatus] = useState<SaveStatus>('saved');
  const [savedAt, setSavedAt] = useState<Date | null>(null);
  const latest = useRef(value); // いちばん新しい値
  const saved = useRef(value); // 保存できた値
  const saving = useRef(false); // 保存のリクエストを送っている最中か
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const saveRef = useRef(save);

  // 毎回の描画で渡された save を覚えておく(保存するときは最新の関数を使う)
  useLayoutEffect(() => {
    saveRef.current = save;
  });

  // 待たずにいますぐ保存する。保存中に呼ばれたら、いまの保存が終わってから続けて保存する
  const flush = useCallback(async (): Promise<void> => {
    if (timer.current) {
      clearTimeout(timer.current);
      timer.current = null;
    }
    if (saving.current) return;
    const v = latest.current;
    if (Object.is(v, saved.current)) {
      setStatus('saved');
      return;
    }
    saving.current = true;
    setStatus('saving');
    try {
      await saveRef.current(v);
      saved.current = v;
      setSavedAt(new Date());
    } catch {
      setStatus('error');
      return;
    } finally {
      saving.current = false;
    }
    if (Object.is(latest.current, v)) setStatus('saved');
    else if (timer.current) setStatus('unsaved'); // 入力が続いている。待ちが終わったら保存する
    else void flush(); // 保存中に入力が止まっていた。続けて保存する
  }, []);

  // 値が変わったら、delay ミリ秒待ってから保存する。待っているあいだに変わったら、待ち直す
  useEffect(() => {
    latest.current = value;
    const changed = !Object.is(value, saved.current);
    // 保存中は「保存中」の表示のままにする(終わったあとで表示を決め直す)
    if (!saving.current) setStatus(changed ? 'unsaved' : 'saved');
    if (!changed) return;
    timer.current = setTimeout(() => {
      timer.current = null;
      void flush();
    }, delay);
    return () => {
      if (timer.current) clearTimeout(timer.current);
      timer.current = null;
    };
  }, [value, delay, flush]);

  // コンポーネントが画面から消えるときは、待たずに保存する
  useEffect(() => () => void flush(), [flush]);

  // 保存が終わっていないあいだは、タブを閉じる前に確認を出す
  useEffect(() => {
    if (status === 'saved') return;
    const onBeforeUnload = (e: BeforeUnloadEvent) => {
      e.preventDefault();
      e.returnValue = true; // preventDefault() に対応していない古いブラウザ向け
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, [status]);

  return { status, savedAt, saveNow: flush };
}

入力が止まるまで待つ部分は、値が変わるたびに setTimeout を仕掛け直すだけです。React の useEffect のリファレンスにあるとおり、依存する値が変わると、前の回のクリーンアップ関数が先に動きます。打ち続けているあいだは、前のタイマーが毎回取り消されます。待つ部分の考え方は、以前の記事「Reactで、入力が止まるまで検索を待つカスタムフックの作り方」と同じです。この記事では、保存の状態の出し分けと、保存を1件ずつ順に送るところに絞ります。

1文字ずつ 150ms おきに「しろたまのメモ」と打ったところ、サーバーが受けた保存のリクエストは、打ち終わってからの1件だけでした。開発用ビルドでは StrictMode によってエフェクトがもう1回ずつ動きますが、リクエストはやはり1件でした。

保存する関数を useRef に入れているのは、使う側が描画のたびに新しい関数を渡しても、flush を作り直さないためです。flush が変わらなければ、flush を依存配列に持つエフェクトも動き直しません。中でも、画面から消えるときに保存するエフェクトが、描画のたびに動いてしまうのを防げます。同じ目的には useEffectEvent もあります。ただ、React の useEffectEvent のリファレンスにあるとおり、それで作った関数はエフェクトの中からしか呼べません。「もう一度保存する」ボタンから呼ぶ flush には使えないので、ref にしています。

前の保存が終わるまで、次の保存を送らない

flush は、保存の最中(saving.current が true)に呼ばれると、何もせずに戻ります。保存が終わったとき、そのあいだに値が変わっていれば、続けて最新の値を保存します。

比べるために、入力が止まるたびに前の保存を待たずにリクエストを送る書き方も用意しました。どちらも「しろたま」と打ち、1件目の保存の最中に「のメモ」を打ち足しています。検証用のサーバーは、1件目の処理に 3000ms、2件目に 200ms かけ、処理が終わった順に内容を上書きします。

書き方 同時に処理していたリクエスト サーバーに残った内容
useAutoSave(前の保存を待つ) 最大1件 しろたまのメモ
前の保存を待たずに送る 最大2件 しろたま

待たずに送る書き方では、後から送った「しろたまのメモ」が先に保存され、遅れて終わった1件目の「しろたま」で上書きされました。画面には「保存しました」と出たままなので、古い内容に戻ったことに気づけません。

useAutoSave では、1件目の応答が返ってから2件目を送ったので、最後に保存されたのは「しろたまのメモ」でした。打ち足しているあいだも表示は「保存中…」のままで、2件目が済んでから「13:04 に保存しました」に変わりました。

失敗したら、ボタンで保存し直せるようにする

保存する関数は、失敗したら例外を投げるように書きます。MDN の fetch() の説明にあるとおり、fetch() はサーバーが 404 や 500 を返しても reject されません(エラーになりません)。Response.ok が false(ステータスが 200〜299 以外)なら、自分で例外を投げます。

ts
// メモを保存する。fetch は 500 などでも失敗にならないので、res.ok を見て投げる
export async function saveNote(text: string): Promise<void> {
  const res = await fetch('/api/note', {
    method: 'PUT',
    headers: { 'content-type': 'text/plain; charset=utf-8' },
    body: text,
  });
  if (!res.ok) throw new Error(`保存に失敗しました(${res.status})`);
}

検証用のサーバーが1回目に 500 を返すようにすると、表示は「保存中…」から「保存できませんでした」に変わり、「もう一度保存する」ボタンが出ました。ボタンを押すと同じ内容でリクエストが送られ、「保存中…」を経て「保存しました」に戻り、ボタンは消えました。

保存の状態を表示するコンポーネントと、メモ欄での使い方

表示は role="status" の要素に出します。MDN の status ロールの説明によると、このロールの要素は aria-live="polite" と同じ扱いになり、中の文が変わると、スクリーンリーダーが読み上げている内容を遮らずに伝えます。ボタンは role="status" の外に置いています。スクリーンリーダーで実際に読み上げさせることは試していません。

tsx
import type { SaveStatus } from './useAutoSave';

type Props = { status: SaveStatus; savedAt: Date | null; onRetry: () => void };

const timeFormat = new Intl.DateTimeFormat('ja-JP', { hour: '2-digit', minute: '2-digit' });

export function SaveStatusText({ status, savedAt, onRetry }: Props) {
  let text = '';
  if (status === 'unsaved') text = '未保存の変更があります';
  else if (status === 'saving') text = '保存中…';
  else if (status === 'error') text = '保存できませんでした';
  else if (savedAt) text = `${timeFormat.format(savedAt)} に保存しました`;

  return (
    <div className="save-status">
      {/* 文字が変わると、スクリーンリーダーが読み上げる */}
      <p role="status" data-status={status}>{text}</p>
      {status === 'error' && (
        <button type="button" onClick={onRetry}>
          もう一度保存する
        </button>
      )}
    </div>
  );
}

メモ欄のコンポーネントでは、入力の値と保存する関数をフックに渡し、返ってきた状態を表示のコンポーネントに渡します。

tsx
import { useState } from 'react';
import { useAutoSave } from './useAutoSave';
import { SaveStatusText } from './SaveStatusText';
import { saveNote } from './api';

export function NoteEditor({ initialText }: { initialText: string }) {
  const [text, setText] = useState(initialText);
  const { status, savedAt, saveNow } = useAutoSave(text, saveNote, 1000);

  return (
    <div>
      <label htmlFor="note">メモ</label>
      <textarea id="note" rows={4} value={text} onChange={(e) => setText(e.target.value)} />
      <SaveStatusText status={status} savedAt={savedAt} onRetry={saveNow} />
    </div>
  );
}

画面から消えるときと、タブを閉じるとき

クリーンアップでタイマーを取り消すだけだと、1秒待っているあいだにメモ欄が画面から消えたとき、打った内容は送られません(この形は試していません)。useAutoSave では、消えるときに動くクリーンアップ関数から flush を呼び、待たずに保存しています。「しろたま」と打ってすぐにメモ欄を閉じたところ、閉じたあとに「しろたま」の保存のリクエストが1件届きました。

タブを閉じる操作には、beforeunload イベントで確認を出します。書き方と注意点は、以前の記事「Reactで、未保存の変更があるときにページを離れる前に確認を出す方法」で書きました。このフックでは、保存の状態が saved 以外のあいだだけ、リスナーを登録しています。保存の最中も、まだ保存は済んでいないので確認を出します。

注意

MDN の beforeunload イベントの説明によると、ページを一度も操作していないと確認ダイアログは出ません。また、スマートフォンでは、別のアプリに切り替えたあとにブラウザごと終了した場合など、beforeunload が発生しないことがあります。このときは確認ダイアログも出ないまま、保存を待っている入力が失われるおそれがあります。MDN はこうした場合に visibilitychange イベントで保存する方法を挙げていますが、この記事のフックには入れておらず、試してもいません。

保存が済む前にタブを閉じようとすると、確認ダイアログが出ました。ダイアログで閉じるのをやめると、そのあと保存が進み、「保存しました」に変わりました。保存が済んでから閉じようとしたときは、ダイアログは出ずにタブが閉じました。

​Posted by 86