紡Book の勉強ノートに戻る
開発記録2026.06.16

モックを実データに繋ぐまで出なかった2つの落とし穴 — 空の src とポートが揃っていない CORS

Next.jsReactCORS紡Book

モバイルで綴ったデータが web で見えるところまで、管を1本通した記録の web 側です。繋いでから踏んだ落とし穴を中心にまとめています。

綴Photo は、モバイルアプリと、それを見せる web、その間に立つ backend でできています。前にモバイル側の「同期の出口」を作って、綴ったデータが backend に届くところまでは通した。今回はその続きで、web 側がその実データを読んで画面に出すところ。まず1画面だけ、お気に入り一覧をモック(本物の backend がまだ繋がっていないとき代わりに置いておく仮のダミーデータ。localStorage のダミー配列を使っていた)から backend の実データに繋ぎ替えた。

お気に入り一覧の置き場所。画面は片方だけ読む。ブラウザ localStorageモックのダミー配列画像URLは必ず入っている閉じても残るbackend の表ではないbackend PostgreSQLFavoriteMemory 表GET /api/favorites/画像が無い行は photo_image が null閉じても残る今回の画面は右を読む。型の写しは交差点で、置き場所ではない。モックは欠けを持たない。実データの null は右の表まで来て初めて出る。

繋ぎ替え自体は、データの取得元を差し替えるだけで、コードとしては小さい。型(TypeScript)も全部通った。でも next dev を立ち上げてブラウザで開いたら、型では出てこなかったものを2つ踏んだ。どちらも「実データには欠けがある」という同じ根から来ていた。

この記事で扱う観察ポイント

  • 画像が無いとき空文字を src に入れると、<img src=""> がページ全体を再取得しにいきます
  • 別ポートで動かしている開発用フロントが、backend の CORS 許可リストから漏れていた
  • モックは「ちゃんとしたデータ」しかくれません。実データに繋いで初めて欠けが出ます

繋ぎ替えは小さい。出たのは画面を開いてから

実データの1件は、写真の署名URL・綴った言葉・気分・日付、といった形をしています。これを画面が使う型に写す変換を1枚書いて(コードは AI に書いてもらった)、取得元のフックを差し替えた。

ユニットテストは変換の対応関係を見ていて、全部緑だった。型も通る。ここまでは「もう動くだろう」という気分になる。でも実際にブラウザで開くと、コンソールに赤が2種類出ていた。

踏んだ①: 画像が無いと <img src=""> がページを再取得する

backend は、写真の画像URLが「まだ無い」とき null で返してきます(今回モバイルから同期したデータは、綴りの文字だけで画像本体を持っていません)。変換のとき、この null を空文字に倒していた。

ts
// 画像URLが無ければ空文字に。URL を勝手にでっち上げたくなかったので
signedUrl: row.photo_image ?? "",

URL を捏造したくなかったので「無ければ空」にした。その判断自体は良かったと思う。

tsx
<img className={s.photo} src={f.signedUrl} alt={f.alt} />  // f.signedUrl が "" のとき src=""

これでコンソールにこう出た。

An empty string ("") was passed to the src attribute. This may cause the browser to download the whole page again over the network.

「画像が無い」の置き場所。空文字はメモリにしか無く、保存しない。backend PostgreSQLPhoto.image が空FavoriteMemory の行はある表に空文字は書いていない読む届いた本文photo_image は nullメモリ・保存しないここには残らない空文字へ画面のメモリsignedUrl は空文字保存しない型は通る空の src は「画像なし」ではなく、今いるページ自身をネットワークから取りにいく。見た目は崩れたアイコンだけ。裏の再取得は保存しない。

直し方は警告の通りで、空文字なら <img> を出さないことにした。無いものは無いものとして、プレースホルダの箱に差し替えます。

tsx
{f.signedUrl ? (
  <img className={s.photo} src={f.signedUrl} alt={f.alt} />
) : (
  // src に null を渡す案もあるが、タグ自体を出さない方が「画像が無い」を明示できる
  <div className={s.photo} aria-hidden />
)}

引っかかったのは、「無い」を空文字で表したこと自体だった。null でも空文字でも型は通ります。でも空文字は「無い」ではなく「長さ0の文字列」として扱われる場所があります。無いものは、無いと分かる形(出さない・null)で持つべきだった、というのが今回の判断。

学びポイント

  • 「データが無い」を空文字で表すと、型は通るのに描画の段で踏みます。無いものは描かない・null で持ちます

踏んだ②: 開発用フロントのポートが CORS から漏れていた

もう1つは CORS エラー。CORS とは、あるサイトが勝手に別のサイトの裏口(API)を叩けないようにブラウザがかける関所のようなもの。叩いていい相手かどうかを backend に毎回確認する仕組みです。

画面(localhost:3001)から backend(localhost:8000)への取得が、本番のリクエストが届く前に弾かれていた。backend 側の許可リストを見たら、こうなっていた。

python
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",  # Next.js dev
]
許可リストは backend の設定。DB にも localStorage にも無い。画面のオリジンlocalhost:3001ブラウザ。保存しない動かしている側OPTIONSbackendlocalhost:8000GET /api/favorites/別オリジン許可を見るCORS 許可リスト設定ファイルlocalhost:3000 だけDB ではないホストが同じでもポートが違えば別オリジン。:3001 はリストに無い。事前確認に許可が返らず、本番の GET は飛ばない。

直しは :3001 を足すだけ。

python
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "http://localhost:3001",  # 開発用フロントもこのポートで動く
]

これも、別の所で似た「揃っていない」を踏んだのを思い出した。同じ役割のものが複数あるなら、設定もそろえて持ちます。開発用フロントのポートを増やしたなら、許可リストもそのポートを知っているべきだった。片方だけ知っている状態は、次に触る人(未来の自分)を確実に止めます。

学びポイント

  • ホスト名が同じでもポートが違えば別オリジン。CORS 許可はポート単位で揃えます
  • 開発フロントを別ポートで動かすなら、backend の許可リストもそのポートを含めます。増やした側と許可する側を、片方だけにしません

このテーマの括り

2つとも、モックで動かしている間は一度も出なかった。それは、モックを書いた自分が「ちゃんとしたデータ」しか想定しなかったからだ。自分が設計した前提の外にある欠け(画像が無い・別ポートから来る)は、モックには最初から入りようがありません。

実データに繋いだ瞬間に出てきたのは、どちらも現実の欠けだった。型は「ある/ない」までしか言ってくれませんし、ユニットテストは自分が用意したきれいな入力しか試していません。繋ぎ替えの本当の検証は、実データを1回ブラウザに通すまで終わりません——コードは AI が書いたが、繋いだあと実データでブラウザを1回開くまで欠けは見えなかった。そこは手を動かして確かめるしかなかった。

繋ぎ替えそのものは小さかったけれど、効いたのは繋いだ後に画面を開いてコンソールを見たことだった。次は、この1画面で出た「画像が無い」をちゃんと扱いながら、残りの画面も実データに繋いでいく。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。