Reactで「名前を付けて保存」と「上書き保存」を作る方法

​​React​File System Access API,TypeScript,カスタムフック

ブラウザで動くメモ帳やエディターに、デスクトップのアプリと同じ「名前を付けて保存」と「上書き保存」を付けます。使うのは File System Access API の showSaveFilePicker() です。ダウンロードと違い、ユーザーが選んだ保存先とファイル名に書き込めます。一度保存したファイルには、同じページを開いている間は、ダイアログを開かずに書き戻せます。

ここでは、保存の処理を useSaveFile というフックにまとめ、テキストを保存する画面で動きを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。ブラウザは、Playwright が起動する Chromium 系のブラウザ(Google Chrome for Testing 153)で、本文では Chromium と書きます。ヘッドレスで試したときも、同じバージョン(153)でした。仮想ディスプレイ(Xvfb)の上で、ヘッドレスではなくウィンドウを表示するモードで動かし、Linux の保存ダイアログ(GTK のファイル選択ダイアログ)のボタンを xdotool でクリックしました。ページは http://localhost:5173/ から配信しています。Windows・macOS・Android の保存ダイアログと、Edge では試していません。

使えるブラウザと条件

MDN の互換性データでは、showSaveFilePicker() は Chrome と Edge が86から、Android 版 Chrome が132から対応しています。Firefox と Safari は対応していません。MDN では実験的な機能(Experimental)の扱いです。

使えるのは安全なコンテキスト(HTTPS や localhost)のページだけです。さらに、ボタンを押すなどのユーザーの操作の直後でないと呼べません(MDN の showSaveFilePicker() の説明)。ユーザーの操作の条件は、後の節で実際に確かめます。

TypeScript では型を書き足す

TypeScript 7.0.2 の DOM の型定義(lib.dom.d.ts)には、FileSystemFileHandle や createWritable() はありますが、showSaveFilePicker はありません。そのまま書くと、tsc は次のようなエラーを出しました(同じエラーが2か所)。

text
src/useSaveFile.ts(28,17): error TS2339: Property 'showSaveFilePicker' does not exist on type 'Window & typeof globalThis'.

そこで、使う分だけ型を書き足します。対応していないブラウザがあるので、省略可能(?)にしておきます。こうしておくと、あるかどうかを調べずに window.showSaveFilePicker(...) を呼んだとき、tsc がエラーにします。検証環境では次のエラーが出ました。

text
src/nocheck.ts(3,10): error TS2722: Cannot invoke an object which is possibly 'undefined'.
ts
// src/file-system-access.d.ts
// TypeScript の lib.dom.d.ts には showSaveFilePicker がないので、使う分だけ書き足す
interface SaveFilePickerOptions {
  suggestedName?: string;
  types?: { description?: string; accept: Record<string, string | string[]> }[];
  excludeAcceptAllOption?: boolean;
  id?: string;
}

interface Window {
  // 対応していないブラウザもあるので、省略可能にしておく
  showSaveFilePicker?: (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
}

保存の処理をまとめたフック

showSaveFilePicker() は、ダイアログで選ばれたファイルの「ハンドル」(FileSystemFileHandle)を返します。書き込むときは、ハンドルの createWritable() で書き込み用のストリームを作り、write() で書いてから close() で閉じます。

上書き保存のために、最後に保存したハンドルを覚えておきます。ハンドルそのものは画面に出さないので、state ではなく useRef に入れました。画面に出すファイル名だけを state にしています。

ts
// src/useSaveFile.ts
import { useRef, useState } from 'react';

export type SaveResult = 'saved' | 'canceled' | 'downloaded';

const TYPES = [{ description: 'テキストファイル', accept: { 'text/plain': ['.txt'] } }];

async function writeText(handle: FileSystemFileHandle, text: string) {
  const writable = await handle.createWritable();
  await writable.write(text);
  await writable.close(); // close() まで終えて、はじめてファイルに書き込まれる
}

function download(text: string, name: string) {
  const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
  const a = document.createElement('a');
  a.href = url;
  a.download = name;
  a.click();
  URL.revokeObjectURL(url);
}

export function useSaveFile(suggestedName: string) {
  // 保存先のハンドルは画面に出さないので、state ではなく ref に持つ
  const handleRef = useRef<FileSystemFileHandle | null>(null);
  const [fileName, setFileName] = useState<string | null>(null);

  async function saveAs(text: string): Promise<SaveResult> {
    if (!window.showSaveFilePicker) {
      download(text, suggestedName);
      return 'downloaded';
    }
    let handle: FileSystemFileHandle;
    try {
      // クリックの処理の中で、ほかの await より先に呼ぶ
      handle = await window.showSaveFilePicker({ suggestedName, types: TYPES });
    } catch (e) {
      if (e instanceof DOMException && e.name === 'AbortError') return 'canceled';
      throw e;
    }
    await writeText(handle, text);
    handleRef.current = handle;
    setFileName(handle.name);
    return 'saved';
  }

  async function save(text: string): Promise<SaveResult> {
    if (!handleRef.current) return saveAs(text);
    await writeText(handleRef.current, text);
    return 'saved';
  }

  return { fileName, save, saveAs };
}

ユーザーがダイアログを閉じると、showSaveFilePicker() は AbortError で失敗します(MDN の Exceptions の節)。これはエラーではなく「保存をやめた」という操作なので、'canceled' を返して画面に知らせるだけにしています。まだ一度も保存していないときの上書き保存は、名前を付けて保存と同じ動きにしました。

画面から呼ぶ

フックを使う画面です。テキストエリアの内容を、2つのボタンで保存します。

tsx
// src/App.tsx
import { useState } from 'react';
import { useSaveFile, type SaveResult } from './useSaveFile';

const MESSAGES: Record<SaveResult, string> = {
  saved: '保存しました',
  canceled: '保存を取り消しました',
  downloaded: 'ダウンロードとして保存しました',
};

export function App() {
  const [text, setText] = useState('買い物メモ\n- 牛乳\n- 卵\n');
  const [message, setMessage] = useState('');
  const { fileName, save, saveAs } = useSaveFile('memo.txt');

  async function run(action: (text: string) => Promise<SaveResult>) {
    try {
      setMessage(MESSAGES[await action(text)]);
    } catch (e) {
      setMessage(`保存できませんでした(${e instanceof Error ? e.name : String(e)})`);
    }
  }

  return (
    <main>
      <p>ファイル:<output id="file">{fileName ?? '(未保存)'}</output></p>
      <textarea value={text} onChange={(e) => setText(e.target.value)} rows={6} cols={40} />
      <p>
        <button onClick={() => run(saveAs)}>名前を付けて保存</button>{' '}
        <button onClick={() => run(save)}>上書き保存</button>
      </p>
      <p id="message" role="status">{message}</p>
    </main>
  );
}

「名前を付けて保存」を押すと、検証環境では次のダイアログが開きました。ファイル名の欄には suggestedName に渡した memo.txt が入っています。右下の「テキストファイル」は、types の description に書いた文言です。

Linux の Chromium で開いた保存ダイアログ。名前の欄に memo.txt が入り、右下にテキストファイルの絞り込みと、キャンセル・保存のボタンがある
「名前を付けて保存」で開いたダイアログ(Linux の GTK のもの)

「保存」を押すと、ページの表示は「ファイル:memo.txt」「保存しました」に変わりました。保存先のフォルダーには memo.txt(31バイト)ができ、中身はテキストエリアのとおりでした。ダイアログで「キャンセル」を押したときは、「保存を取り消しました」と出て、ファイルは変わりませんでした。

上書き保存はダイアログを開かない

テキストエリアを「買い物メモ」「- 牛乳」の2行に減らして「上書き保存」を押すと、ダイアログも確認の画面も出ずに保存されました。memo.txt は31バイトから25バイトになり、前の内容の「- 卵」は残っていませんでした。createWritable() は、オプションの keepExistingData を指定しなければ空の状態から書き始めます(MDN の createWritable() の説明)。そのため、毎回ファイル全体を書き直すことになります。

保存した後は、アドレスバーにファイルのアイコンが出ていました。押すと、このページがどのファイルを編集できるかが表示され、「アクセスを削除」と「完了」のボタンがありました。

アドレスバーのアイコンを押すと「http://localhost:5173 は「memo.txt」を編集できます」と出て、アクセスを削除と完了のボタンがある
保存した後に、アドレスバーのファイルのアイコンを押したところ

「名前を付けて保存」のダイアログで名前を shopping に変えて保存し、続けて上書き保存すると、shopping のほうに書き込まれ、memo.txt は変わりませんでした。保存のたびに ref のハンドルを入れ替えているので、上書き保存の先は、最後に「名前を付けて保存」で選んだファイルになります。

拡張子と、既にあるファイル

ダイアログで拡張子を付けずに shopping と入力して保存すると、ファイル名は shopping のままで、types に .txt を書いていても、.txt は足されませんでした。suggestedName に拡張子まで入れておけば最初に出る名前には付きますが、ユーザーが名前を打ち直すと付きません。

注意

検証環境の GTK のダイアログでは、既にある memo.txt を選んで保存したとき、確認の画面は出ず、そのまま新しい内容に置き換わりました。

クリックの直後に呼ぶ

showSaveFilePicker() は、ユーザーの操作の直後(transient activation と呼ばれる状態)でないと呼べません。検証用のページで、呼ぶタイミングを変えて試しました。

呼んだタイミング 結果
ページを開いたとき(クリックなし) SecurityError
クリックしてから1秒待って ダイアログが開いた
クリックしてから6秒待って SecurityError

SecurityError のメッセージは、どちらも次のとおりでした。

text
SecurityError: Failed to execute 'showSaveFilePicker' on 'Window': Must be handling a user gesture to show a file picker.

MDN の transient activation の説明にあるとおり、この状態は時間がたつと切れます。検証でも、クリックから1秒後なら開けましたが、6秒後には切れていました。そのため、時間のかかる処理を showSaveFilePicker() より先に await しないようにします。フックでは、ダイアログを先に開き、書き込むのはその後にしています。

close() を呼ぶまでファイルは変わらない

write() の後に close() を呼ばずにおくと、どうなるかも試しました。ダイアログで noclose.txt を選んで write() まで終えた時点で、保存先には空(0バイト)の noclose.txt と、noclose.txt.crswap というファイルがありました。close() を呼ぶと .crswap はなくなり、noclose.txt に書いた内容が入りました。

MDN の createWritable() の説明にも、ストリームを閉じるまで変更はファイルに反映されず、ふつうは一時ファイルに書いておき、閉じたときに置き換える作りだと書かれています。close() を忘れると、保存したつもりでも中身が入りません。

対応していないブラウザとヘッドレスでのテスト

フックでは、window.showSaveFilePicker がないときは、<a download> を使ったダウンロードに切り替えています。この場合はブラウザのダウンロードとして保存されるので、同じファイルへの上書き保存はできません。検証では、Chromium の showSaveFilePicker をページの読み込み前に消して「名前を付けて保存」を押し、memo.txt がダウンロードされ、「ダウンロードとして保存しました」と出ることを確かめました。

Playwright でヘッドレスの Chromium を動かしたときは、showSaveFilePicker 自体はありましたが、ボタンを押すとダイアログは出ずに AbortError になり、「保存を取り消しました」と出ました。この記事の検証では、ウィンドウを表示するモードでダイアログを操作して、保存まで確かめました。

​Posted by 86