Article

🖼️ ImagePickerのfile URIをDBに保存しない:React Nativeで画像を永続化する設計

reactnative, expo, firebase, typescript

Expo ImagePickerで選んだ画像をそのままDBへ保存すると、あとから画像が読めなくなることがあります。

理由は、ImagePickerが返すuriが端末内のローカルファイルを指すことがあるためです。Expo公式ドキュメントの例でも、file:///.../cache/... のようなURIが返っています。

端末内URIは「その端末で今読める場所」であって、複数端末や再インストール後まで使える共有URLではありません。

まず結論

共有データとして画像を扱うなら、保存境界を次のようにします。

ImagePicker
  ↓
端末内のlocal URI
  ↓
Cloud Storageへアップロード
  ↓
Storage上の参照/URL
  ↓
DBへ保存

DBへ書く前に、画像を永続ストレージへ移すのがポイントです。

local URIをそのまま保存すると何が起きるか

const result = await ImagePicker.launchImageLibraryAsync();

if (!result.canceled) {
  const localUri = result.assets[0].uri;
}

このlocalUriをFirestoreなどへそのまま保存すると、別端末からは読めません。

さらにURIがキャッシュやアプリ管理下の一時領域を指す場合、そのファイルを共有データの永続参照として扱うのは危険です。

そのため、ローカルURIは「アップロード前の一時入力」として扱います。

永続化の責務をrepositoryに寄せる

作成画面と編集画面でアップロード処理を個別実装すると挙動がずれやすいため、保存境界へ寄せます。

type PhotoInput =
  | { type: 'keep' }
  | { type: 'remove' }
  | { type: 'replace'; localUri: string };
  • keep: 既存画像を変更しない
  • remove: 画像を削除する
  • replace: 新画像をアップロードして差し替える

string | null | undefinedだけで扱うより、意図を型で表せます。

アップロード後にDBを書き込む

async function saveItem(input: PhotoInput) {
  let uploadedPath: string | null = null;

  try {
    const photoUrl =
      input.type === 'replace'
        ? await uploadPhoto(input.localUri, path => {
            uploadedPath = path;
          })
        : input.type === 'remove'
          ? null
          : undefined;

    await saveToDatabase({ photoUrl });
  } catch (error) {
    if (uploadedPath) {
      await deleteUploadedFile(uploadedPath).catch(() => {
        // cleanup失敗は別ログへ
      });
    }

    throw error;
  }
}

アップロード失敗時にDBだけ成功した状態を作らないことが重要です。

逆に、アップロード後のDB保存で失敗した場合は、その保存試行で新規作成したファイルだけを削除します。

これは補償処理という考え方で、複数システムをまたぐ途中失敗を打ち消します。

Storage Rulesでも検証する

Firebase Storage Security Rulesでは、認証だけでなくファイルサイズやMIMEタイプも検証できます。

allow write: if request.auth != null
  && request.resource.size < 10 * 1024 * 1024
  && request.resource.contentType.matches('image/.*');

10MiBはFirebaseの上限ではなく、アプリ側で決めた例です。

クライアントの事前チェックだけに頼らず、Storage Rulesでも制限します。

上書きより新規パスを使う

差し替え時に同じStorageパスを上書きすると、DB更新失敗時に以前の画像まで失う可能性があります。

新画像はUUIDなどを含む別パスへ保存し、

  1. 新画像をアップロード
  2. DBの参照先を更新
  3. 必要なら旧画像を削除

という順序にすると途中失敗に強くなります。

テストはデータの寿命をまたぐ

最低限、次を確認します。

  • DBにはlocal URIではなく永続参照が入る
  • 元のローカル画像を消しても表示できる
  • アプリ再起動後も表示できる
  • 別画面や共有画像生成からも読める
  • Rulesで未認証・権限なし・不正MIME・過大ファイルを拒否できる
  • DB保存失敗時に今回アップロードしたファイルだけcleanupされる

選択直後に同じ画面で見えるだけでは不十分です。

まとめ

  • local URIは一時入力として扱う
  • DB書き込み前にCloud Storageへ移す
  • keep/remove/replaceを型で分ける
  • StorageとDBの途中失敗には補償処理を入れる
  • Security Rulesでもサイズ・MIME・権限を検証する
  • 再起動や元ファイル削除まで含めてテストする

この設計は、自分が開発している旅行サポートアプリ TripSupporter の予定写真保存でも使っています。

この修正は 2026年10月4日時点では未リリース です。コードのmain反映と、App Storeでユーザーへ配布される状態は分けて管理しています。

参考