?? Defaults Overwrite Fields the Form Never Sent
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Build the object you pass to a Supabase update as formData.get(...) ?? default and an unsent field is overwritten with the default every time, rather than keeping its existing value.
The short version
Build the object you pass to Supabase’s .update() as formData.get(...) ?? default and, when that field is unsent, it is overwritten with the default every time rather than keeping its existing value. ?? conflates “there is no value” with “the field was not touched”, while the DB’s update() writes whatever key it is given — an implementation that does not separate the two will always go wrong on an edit form.
The fix is to include the key in the object only when there is a value.
What it looks like
Saving from the admin ad-slot edit form, slots already at 「掲載中」 (running) or 「申込済」 (booked) quietly rolled back to 「空き」 (vacant), simply because the form did not include availability.
No error appears. The save itself succeeds and the other fields (label, description and so on) update correctly. Only availability rolls back, and it happens on every single save.
Why
After reading the form’s inputs, updatePlacement passed them to .update() like this.
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,
// Availability only reflects a manual override from the edit form. status (retired)
// is untouched = preserve the existing value.
availability: val(formData.get("availability")) ?? "空き",
label: f.label,
// ...
})
.eq("id", id);
The comment says “preserve the existing value” and the implementation does the opposite. val() is a helper that trims a string and returns null if it is empty. Whether the field is unsent, or present with an empty value, the result of formData.get("availability") through it is the same null. ?? "空き" replaces that null with the default the moment it sees it, so the availability key always holds some string and .update() writes it straight to the DB.
placements.availability is a column taking one of five values — 空き / 商談中 / 申込済 / 掲載中 / 終了 (vacant / in negotiation / booked / running / ended) — bound by a CHECK constraint.
add column if not exists availability text not null default '空き'
check (availability in ('空き','商談中','申込済','掲載中','終了')),
Which is to say this form had no way to express the intent “do not touch availability”, and wrote “空き” even on saves that meant to leave it alone. Save from a screen whose edit form has no availability input, or from a screen where that input is conditionally shown in future, and a value like 掲載中 goes back to 空き every time, no questions asked.
Fixing it
We take the value into a variable once and vary the presence of the key itself on the object passed to .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);
When availabilityOverride is null (unsent or empty), the spread ...(availabilityOverride ? {...} : {}) expands to an empty object and the availability key itself is not in the payload passed to .update(). Supabase’s .update() updates only the keys it is given, so with no key that column is never touched and the DB’s existing value stays.
The point is the move from “fill in a default” to “omit the key”. ?? default is a way of deciding a value’s contents; it does not decide whether that field is updated. When a partial update needs to respect whether a field is present, the branch has to be on the presence of the key, not on the value.
Preventing a repeat
The post-fix code carries that reasoning as a comment.
// Availability only reflects a manual override from the edit form. If the form does not
// have it, drop it from the payload and preserve the existing value (avoids the footgun
// of rolling back to "空き" when the field is absent). status (retired) is untouched too.
const availabilityOverride = val(formData.get("availability"));
Moving from the “assign a default value” pattern to the “branch on the presence of the key” pattern is so that when readSlotFields’s conditionals change and more forms omit availability, the same code no longer produces the same accident. Writing it so that what gets decided each time is whether the field goes in the payload, rather than how to fill in its contents, keeps breakage from a change in form composition inside the range that compilation or review can catch.
よくある質問
Q1Why does formData.get(...) ?? default overwrite an unsent field?
val() trims and returns null for an empty string, so an unsent field and an empty submitted one both become null. ?? default replaces null the moment it sees it, so untouched and explicitly reset are the same, and the update object always carried the key.
Q2What was it changed to?
Take the value once into availabilityOverride, then build the update object with the spread ...(availabilityOverride ? { availability: availabilityOverride } : {}). The key goes in only when there is a value; otherwise it is omitted and the existing value stays.
Q3Is this specific to Supabase?
No. The cause is not how .update() is called but how the object is built by filling in a default with ??. Any implementation that submits some form fields for a partial update produces the same result with the same code, Supabase or not.
Q4Why had nobody noticed?
The comment stated the intent to preserve the existing value and the implementation did the opposite. Nothing shows while saving from a form that includes availability; only a form without that field makes it quietly roll back. The fix landed as a Minor review finding.
確認した環境
- Next.js 16.2.7 / @supabase/supabase-js 2.106.2
- Fixed on 2026-06-18 after a Minor review finding
この記事の根拠
- TypeScriptファイル 106〜118行目コミット c0e4e4d
- TypeScriptファイル 14〜17行目コミット c0e4e4d
- TypeScriptファイル 106〜121行目コミット df622fe
- SQLファイル 24〜25行目コミット 6f62043
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。