Reactで、ブラウザに保存できる容量と使用量を表示する方法

​​React​IndexedDB,Playwright,Storage API,TypeScript

ブラウザがこのサイトに許している保存容量と、いま使っている量は、navigator.storage.estimate() で取得できます。Promise で usage(使用量)と quota(上限)がバイト単位で返ってくるので、React では取得する関数をカスタムフックにまとめ、結果を state に入れて表示します。一度取得した値は、あとで保存しても自動では更新されないため、IndexedDB などに書き込んだあとで取り直します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153(ヘッドレス)です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。保存したのは IndexedDB だけで、Cache API や Origin Private File System には書き込んでいません。Firefox と Safari、navigator.storage.persist() で永続化を許可された状態、容量の上限まで書き込んだときの動きは試していません。

使用量と上限を取得するカスタムフック

estimate() は Promise を返すので、useEffect の中で呼んで結果を state に入れます。保存のあとで取り直せるように、取得する処理は refresh として外にも渡します。

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

export type StorageInfo =
  | { status: 'loading' }
  | { status: 'unsupported' }
  | { status: 'error'; message: string }
  | { status: 'ready'; usage: number; quota: number };

export function useStorageEstimate() {
  const [info, setInfo] = useState<StorageInfo>({ status: 'loading' });

  const refresh = useCallback(async () => {
    // http のページ(localhost 以外)では navigator.storage がない
    if (!navigator.storage?.estimate) {
      setInfo({ status: 'unsupported' });
      return;
    }
    try {
      const { usage = 0, quota = 0 } = await navigator.storage.estimate();
      setInfo({ status: 'ready', usage, quota });
    } catch (e) {
      setInfo({ status: 'error', message: String(e) });
    }
  }, []);

  useEffect(() => {
    refresh();
  }, [refresh]);

  return { info, refresh };
}

TypeScript(typescript 7.0.2)の DOM の型定義では、StorageEstimate の quota と usage はどちらも number | undefined です。そのため、分割代入で 0 を初期値にしています。

MDN の estimate() の説明によると、返ってくる値はおおよその数です。圧縮や重複の排除、セキュリティのための値のぼかしがあるので、正確な数にはなりません。quota は、そのサイトが使えると見込める容量を控えめに見積もった値です。

バイト数を読みやすくして表示する

返ってくる値はバイト数なので、そのまま出すと桁が多すぎます。1024 ごとに単位を上げる関数を用意しました。

ts
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];

// 1KB = 1024B として、小数1桁までで表す
export function formatBytes(bytes: number): string {
  let value = bytes;
  let i = 0;
  while (value >= 1024 && i < UNITS.length - 1) {
    value /= 1024;
    i++;
  }
  const digits = i === 0 ? 0 : 1;
  return `${value.toLocaleString('ja-JP', { maximumFractionDigits: digits })} ${UNITS[i]}`;
}

表示するコンポーネントは、フックの info を受け取ります。使った割合は progress 要素で棒にし、上限から使用量を引いた残りも出しています。

tsx
import { formatBytes } from './formatBytes';
import type { StorageInfo } from './useStorageEstimate';

type Props = { info: StorageInfo; onRefresh: () => void };

export function StorageMeter({ info, onRefresh }: Props) {
  if (info.status === 'loading') return <p>容量を調べています…</p>;
  if (info.status === 'unsupported') return <p>このブラウザでは容量を調べられません。</p>;
  if (info.status === 'error') return <p>容量を取得できませんでした({info.message})</p>;

  const { usage, quota } = info;
  const percent = quota > 0 ? (usage / quota) * 100 : 0;

  return (
    <div className="meter">
      <p>
        使用中 <span className="num" data-testid="usage">{formatBytes(usage)}</span>
        {' '}/ 上限 <span data-testid="quota">{formatBytes(quota)}</span>
      </p>
      <progress max={quota} value={usage} aria-label="保存容量の使用率" />
      <p>
        <span data-testid="percent">{percent.toFixed(2)}%</span> を使用(残り{' '}
        <span data-testid="remain">{formatBytes(quota - usage)}</span>)
      </p>
      <button type="button" onClick={onRefresh}>再計算</button>
    </div>
  );
}

保存したら refresh を呼んで取り直す

ページを開いたときに1回取得しただけでは、あとから IndexedDB に書き込んでも表示は変わりません。そこで、書き込みが終わってから refresh を呼びます。次の App には、IndexedDB に 10MB のデータを1件書き込むボタンと、データベースを消すボタンを置きました。比べるために、書き込んだあと refresh を呼ばないボタンも置いています。

tsx
function App() {
  const { info, refresh } = useStorageEstimate();

  const save = async () => {
    await saveToIndexedDB();
    await refresh();
  };
  const remove = async () => {
    await deleteIndexedDB();
    await refresh();
  };

  return (
    <>
      <StorageMeter info={info} onRefresh={refresh} />
      <div className="actions">
        <button type="button" id="save" onClick={save}>10MB を保存</button>
        <button type="button" id="saveOnly" onClick={() => saveToIndexedDB()}>10MB を保存(再計算しない)</button>
        <button type="button" id="remove" onClick={remove}>保存したデータを消す</button>
      </div>
    </>
  );
}

saveToIndexedDB は、Uint8Array で作った 10MB のデータを put し、トランザクションの oncomplete で解決する Promise を返す関数です。deleteIndexedDB は indexedDB.deleteDatabase() を Promise にしたものです。

水色の枠の中に「使用中 20 MB / 上限 10 GB」と、ほとんど伸びていない棒、「0.19% を使用(残り 10 GB)」、「再計算」ボタンが並ぶ。枠の下に「10MB を保存」「10MB を保存(再計算しない)」「保存したデータを消す」の3つのボタンがある
「10MB を保存」を2回押したあとの画面(幅 375px)

Chromium で、ディスクにデータを残すふつうのプロファイルを使って試しました。使用量の表示は、開いた直後の 0 B から、「10MB を保存」を押すたびに 10 MB、20 MB と増えました。続けて「10MB を保存(再計算しない)」を押すと、表示は 20 MB のままでした。このとき estimate() を直接呼ぶと、usage はもう約 30MB になっていました。「再計算」を押すと、表示も 30 MB に変わりました。

text
もう10MB 保存後 画面 {"usage":"20 MB","quota":"10 GB","percent":"0.19%","remain":"10 GB",...}
再計算しないボタンで保存後 画面 {"usage":"20 MB","quota":"10 GB","percent":"0.19%","remain":"10 GB",...}
そのときの estimate {"usage":31461058,"quota":10768879298,"quota - usage":10737418240,"usageDetails":{"indexedDB":31461058}}
再計算ボタンを押した後 画面 {"usage":"30 MB","quota":"10 GB","percent":"0.29%","remain":"10 GB",...}

出力の「画面」の行は、画面に出ている文字を読んだものです(長いので一部を ... で省きました)。「estimate」の行は、同じページで navigator.storage.estimate() を呼んで返ってきた値そのものです。

estimate() の値が変わったことを知らせるイベントは、Storage の仕様の StorageManager にはありません。保存する処理が別のコンポーネントにある場合も、書き込みが終わったところで refresh を呼ぶか、「再計算」のようなボタンで取り直せるようにしておきます。

データベースを消して取り直すと、使用量は 2.7 KB(2813 バイト)まで減りました。開いた直後の 0 B には戻りませんでした。3秒待ってから取り直しても、同じ値でした。

text
データを消した後 estimate {"usage":2813,"quota":10737421053,"quota - usage":10737418240,"usageDetails":{"indexedDB":2813}}
3秒後の estimate {"usage":2813,"quota":10737421053,"quota - usage":10737418240,"usageDetails":{"indexedDB":2813}}

Chromium は、標準にない usageDetails で、使用量の内訳(ここでは indexedDB)も返しました。MDN では非標準の項目とされていて、TypeScript の StorageEstimate の型にも入っていません。

上限の値は、環境やモードで変わる

上の出力では、quota が使用量と一緒に増えていて、quota - usage はいつも 10737418240(10GiB)でした。つまり、この環境の Chromium では「残り」が一定で、上限のほうが使った分だけ大きくなっています。画面で残りが 10 GB のまま変わらなかったのはこのためです。

text
開いた直後 estimate {"usage":0,"quota":10737418240,"quota - usage":10737418240,"usageDetails":{}}
10MB 保存後 estimate {"usage":10487856,"quota":10747906096,"quota - usage":10737418240,"usageDetails":{"indexedDB":10487856}}

MDN の、保存できる容量とデータが消される条件の説明では、Chromium 系のブラウザで1つのオリジンが使える容量は、ディスク全体の60%までとされています。Firefox や Safari は、また別の決め方です。同じページには、上限は空き容量ではなくディスク全体の大きさから計算するため、上限まで保存できないことがあるとも書かれています。上限の数字は決め打ちせず、そのつど estimate() で取得して表示します。

ディスクにデータを残さないコンテキスト(Playwright の browser.newContext() で作ったもの)で同じページを開くと、上限は 3 GB と出ました。なお MDN によると、シークレットウィンドウのようなプライベートブラウジングでは、別の上限が使われることがあります。

text
ディスクに残さないコンテキスト estimate {"usage":0,"quota":3221225472,"quota - usage":3221225472,"usageDetails":{}}

http のページや sandbox の iframe では取得できない

仕様では、StorageManager は安全なコンテキスト(HTTPS のページや localhost)でだけ使えます。--host-resolver-rules で demo.example という名前を検証用 VM 自身に向け、http://demo.example:8080/ で同じページを開くと、isSecureContext は false で、navigator.storage は undefined でした。フックの最初の判定が働き、画面には「このブラウザでは容量を調べられません。」と出ました。

text
http://demo.example:8080 isSecureContext false
http://demo.example:8080 navigator.storage undefined
http://demo.example:8080 画面 {"usage":null,"quota":null,"percent":null,"remain":null,"text":"このブラウザでは容量を調べられません。"}

navigator.storage があっても、estimate() が失敗する場合があります。MDN の estimate() の説明では、オリジンが opaque なときや、ユーザーが保存を禁止しているときに TypeError になるとされています。opaque なオリジンとは、どのオリジンとも一致しない特別なオリジンのことです。HTML の仕様では、sandbox 属性の iframe は allow-same-origin を付けない限り、中身が opaque なオリジンになります。そこで、allow-scripts だけを付けた iframe の中で estimate() を呼んでみました。Promise は次の TypeError で reject されました。フックで try と catch を使っているのは、このような場合に備えるためです。

text
sandbox の iframe の中 TypeError: Failed to execute 'estimate' on 'StorageManager': The operation is not supported in this context.

​Posted by 86