Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Supabase の update で未送信フィールドを `??` の既定値にすると既存の値が上書きされる

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Supabase の update に渡す値を `formData.get(...) ?? デフォルト値` で組み立てると、フィールドが未送信のときに既存の値ではなく常にデフォルト値で上書きされる。

結論

Supabase の .update() に渡すオブジェクトを formData.get(...) ?? デフォルト値 で組み立てると、そのフィールドが未送信のときに既存の値ではなく常にデフォルト値で上書きされます。 ?? は「値が無い」を「フィールドを触っていない」と混同しますが、DB の update() は渡されたキーをそのまま書き込むため、この2つを区別しない実装は編集フォームで必ず事故ります。

直し方は、値があるときだけオブジェクトにキー自体を含める形に変えることです。

症状

管理画面の枠(広告掲載枠)編集フォームで保存すると、availability(空き状況)を含まないフォームから保存しただけで、既に「掲載中」や「申込済」になっている枠が黙って「空き」に巻き戻る、という不具合がありました。

エラーは出ません。保存操作自体は成功し、他のフィールド(labeldescription など)は正しく更新されます。巻き戻るのは availability だけで、しかも保存のたびに毎回起きます。

原因

updatePlacement は、フォームの入力値を読んだあと .update() にこう渡していました。

function val(raw: FormDataEntryValue | null): string | null {
  const s = String(raw ?? "").trim();
  return s || null;
}
await supabase
  .from("placements")
  .update({
    slot_seconds: f.slot_seconds,
    // 空き状況は編集フォームの手動上書きのみ反映。status(廃止)は触らない=既存値を温存。
    availability: val(formData.get("availability")) ?? "空き",
    label: f.label,
    // ...
  })
  .eq("id", id);

コメントには「既存値を温存する」と書いてありますが、実装はその逆でした。val() は文字列をトリムして空なら null を返すヘルパーです。フィールドが未送信でも、フィールドがあって中身が空文字でも、formData.get("availability") を通した結果は同じ null になります。?? "空き" はその null を見た瞬間にデフォルト値へ置き換えるため、availability キーには毎回何らかの文字列が入り、.update() はそれをそのまま DB に書き込みます。

placements.availability空き / 商談中 / 申込済 / 掲載中 / 終了 の5値を取る列で、CHECK 制約で縛られています。

add column if not exists availability      text not null default '空き'
  check (availability in ('空き','商談中','申込済','掲載中','終了')),

つまりこのフォームは、「availability を触らない」という意図を表現する手段を持たないまま、触らないつもりの保存でも常に “空き” を書き込んでいたことになります。編集フォームに availability の入力欄が無い画面や、将来その欄が条件分岐で出し分けられる画面から保存すると、掲載中 のような値は毎回問答無用で 空き へ戻ります。

直し方

値を一度変数で受け、.update() に渡すオブジェクト側でキーの有無ごと出し分けるようにしました。

const availabilityOverride = val(formData.get("availability"));
const supabase = await createSupabaseServerClient();
await supabase
  .from("placements")
  .update({
    slot_seconds: f.slot_seconds,
    ...(availabilityOverride ? { availability: availabilityOverride } : {}),
    label: f.label,
    // ...
  })
  .eq("id", id);

availabilityOverridenull(未送信または空文字)のときは、スプレッド構文 ...(availabilityOverride ? {...} : {}) が空オブジェクトに展開され、availability というキー自体が .update() に渡す payload に含まれません。 Supabase の .update() は渡されたキーだけを更新する仕様なので、キーが無ければその列は一切触られず、DB 側の既存値がそのまま残ります。

「デフォルト値で埋める」から「キーごと省く」への変更が要点です。?? デフォルト値 は値の中身を決める書き方であって、そのフィールドを更新するかどうかまでは決めてくれません。部分更新でフィールドの有無を尊重したいときは、値ではなくキーの有無で分岐する必要があります。

再発防止

修正後のコードには、この判断をそのままコメントとして残しています。

// 空き状況は編集フォームの手動上書きのみ反映。フォームに無ければ payload から外して
// 既存値を温存する(フィールド欠落時に "空き" へ巻き戻す footgun を防ぐ)。status(廃止)も触らない。
const availabilityOverride = val(formData.get("availability"));

「デフォルト値を代入する」パターンをやめて「キーの有無で分岐する」パターンに変えたのは、今後 readSlotFields の条件分岐が変わって availability を含まないフォームが増えても、同じ書き方のままでは同じ事故が起きない形にするためです。値の中身をどう補うかではなく、そのフィールドを payload に含めるかどうかを毎回判断する書き方にしておけば、フォームの構成が変わったときに壊れるのはコンパイルやレビューで気づける範囲に収まります。

よくある質問

Q1なぜ formData.get(...) ?? デフォルト値 だと未送信フィールドが上書きされるのですか?

val() はトリムして空文字なら null を返すヘルパーで、フィールドが未送信でも空文字送信でも同じ null になります。?? デフォルト値 は null を渡された時点でデフォルト値に置き換えるため、「触っていない」と「明示的にデフォルトへ戻す」を区別できず、update オブジェクトには常に availability キーが乗って上書きされていました。

Q2この修正はどんな書き方に変えたのですか?

const availabilityOverride = val(formData.get("availability")) で値を一度受け取り、update に渡すオブジェクトは ...(availabilityOverride ? { availability: availabilityOverride } : {}) というスプレッド構文で組み立てるよう変更しました。値があるときだけ availability キー自体を含め、無ければキーごと省いて既存値をそのまま残します。

Q3これは Supabase 固有の問題ですか?

いいえ。原因は .update() の呼び出し方ではなく、?? でデフォルト値を補ってオブジェクトを組み立てる側にあります。フォームの一部フィールドだけを送信して部分更新する実装であれば、Supabase に限らず同じ書き方をすれば同じように起きます。

Q4なぜこれまで気づかれなかったのですか?

このコードにはもともと「既存値を温存する」という意図のコメントが付いていましたが、実装はその逆でした。availability を含むフォームから保存している間は症状が出ず、availability 欄を持たない編集フォームが使われて初めて、既存の状態が黙って空きへ巻き戻ります。修正はコードレビューの Minor 指摘として入りました。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2
  • 2026-06-18 にレビュー指摘(Minor)を受けて修正

この記事の根拠

  • TypeScriptファイル 106〜118行目コミット c0e4e4d
  • TypeScriptファイル 14〜17行目コミット c0e4e4d
  • TypeScriptファイル 106〜121行目コミット df622fe
  • SQLファイル 24〜25行目コミット 6f62043

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。