datepicker 写経 — 日付入力と送信ペイロード
テクノロジ系 / L18 datepicker — 日付入力と送信ペイロード (vue-datepicker)
datepicker 写経 — 日付入力と送信ペイロード
> forms/ch01 のフォームに「期限 (dueDate)」 を足す。 ただの input ではなくライブラリ製のカレンダーを使い、
> 選んだ日付を壊さずにサーバーへ送る ところまでをやる。
0. 導入: なぜカレンダーは「送信で壊れる」のか
<input type="date"> で済むならそれでいい。 だが要件 (範囲選択 / 和暦 / 時刻つき / locale) が増えると
ライブラリ製の datepicker を使うことになる。 ここで初学者が必ず踏むのが タイムゾーンによる日付ズレ だ。
このトピックは「直し方を覚える」 前に 一度壊す。 壊れる様を見ないと、 なぜ対策が要るのか腹落ちしないからだ。
1. セットアップ
cd frontend/vue # 既に node_modules にある前提。 無ければ: # bun add @vuepic/vue-datepicker
DueDateField.vue を作る。
<script setup>
import { ref } from 'vue'
import VueDatePicker from '@vuepic/vue-datepicker' // ← default import (波括弧なし)
import '@vuepic/vue-datepicker/dist/main.css' // ← CSS の読み込みを忘れない
const dueDate = ref(null)
</script>
<template>
<div>
<VueDatePicker v-model="dueDate" locale="ja" />
<p>選択値: {{ dueDate }}</p>
<p>型: {{ typeof dueDate }}</p>
</div>
</template>> ⚠️ このサンプルは v11 系 (package.json: ^11.0.2)。 v11 は default export なので 波括弧なし が正しい。
> ここで import { VueDatePicker } (波括弧あり) と書くと undefined になり描画されない / ビルドが落ちる。
> ただし v12 以降は named export に変わり、 波括弧ありが正しくなる。 版で逆転する点に注意 — 詳細は README の「import の波括弧問題」 参照。
ブラウザで日付を選び、 選択値 に注目する。 Wed Jun 24 2026 00:00:00 GMT+0900 のような Date オブジェクト が表示され、
型 は object になる。 これが落とし穴の入口。
2. 壊す: 素朴に送ると前日にズレる
「送信」 を模した処理を書く。 多くの初学者は Date を JSON にするとき toISOString() を使う。
function buildPayloadBroken(date) {
return { due: date.toISOString().slice(0, 10) } // ← これがバグる
}2026-06-24 の 0:00 (JST) を選んで実行すると:
{ due: "2026-06-23" } // ← 6/24 を選んだのに 6/23 になる!なぜか
toISOString() は UTC に変換する。 日本 (UTC+9) の 6/24 00:00 は、 UTC では 6/23 15:00。
日付部分だけ切り出すと 前日 になる。 深夜〜早朝に選択したユーザーで必ず再現する、 タチの悪いバグだ。
| 入力 (JST) | toISOString() の結果 | 切り出した日付 |
|---|---|---|
| 6/24 00:00 | 2026-06-23T15:00:00.000Z | 6/23 (NG) |
| 6/24 15:30 | 2026-06-24T06:30:00.000Z | 6/24 (たまたま合う) |
> 昼に選ぶと正しく見えるので、 開発中は気づかず、 本番で深夜利用者からバグ報告が来る。 最悪のパターン。
3. 直す (本命): model-type で v-model を文字列に固定
一番良いのは 入口で Date を文字列にしてしまう こと。 model-type prop を渡すと、
v-model に入る値そのものが指定フォーマットの文字列になる。
<template>
<VueDatePicker
v-model="dueDate"
model-type="yyyy-MM-dd"
locale="ja"
/>
<p>選択値: {{ dueDate }}</p> <!-- "2026-06-24" (文字列) -->
</template>これで dueDate.value は最初から "2026-06-24" (文字列)。
function buildPayload(dueDate) {
return { due: dueDate } // そのまま送れる。 変換不要 = ズレようがない
}- Date がアプリ内を流れなくなる → 送信時に変換し忘れる事故が構造的に消える。
- 日時も送るなら
model-type="yyyy-MM-dd'T'HH:mm:ss"。 - まずこれを試す。 大半のケースはこれで終わり。
4. 直す (保険): 既存コードが Date 前提なら送信前に変換
既存実装が Date を前提に組まれていて model-type を入れにくい場合は、 送信処理だけ 直す。
鉄則は toISOString() を使わない。
// ローカル日付を YYYY-MM-DD に組む (タイムゾーン安全)
function toLocalYMD(d) {
if (!d) return null // ← null ガードも兼ねる (5節)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0') // getMonth は 0 始まり → +1
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function buildPayload(date) {
return { due: toLocalYMD(date) } // 6/24 → "2026-06-24" 正しい
}getFullYear / getMonth / getDate は ローカルタイムゾーンの値 を返すので、 UTC 変換が挟まらず、 ズレない。
> Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(d) でも YYYY-MM-DD が得られる。
> タイムゾーンを明示したいときはこちら。
5. 未選択の null をガードする
datepicker の初期値・クリア時は null。 これを必須フィールドにそのまま送るとサーバーに弾かれる (422)。
const dueDate = ref(null) const canSubmit = computed(() => dueDate.value !== null) // UI 側: ボタン無効化 </script> <template> <VueDatePicker v-model="dueDate" model-type="yyyy-MM-dd" locale="ja" /> <button :disabled="!canSubmit" @click="onSubmit">登録</button> </template>
function onSubmit() {
if (dueDate.value === null) return // ロジック側: 二重防御 (ch01 と同じ作法)
// fetch(...)
}ch01 の 「disabled (UI) + 判定 (ロジック) の二重防御」 がここでもそのまま効く。
6. range 選択は配列になる (送信設計が変わる)
範囲選択を有効にすると、 v-model は 2 要素の配列 [開始, 終了] になる。 送信ペイロードの形が変わる。
<VueDatePicker v-model="period" :range="true" model-type="yyyy-MM-dd" locale="ja" />
const period = ref(null) // 選択後: ["2026-06-24", "2026-06-30"]
function buildPayload(period) {
if (!period) return { from: null, to: null }
return { from: period[0], to: period[1] } // model-type 指定済みなら各要素も文字列
}> model-type を付けていないと period[0] は Date のまま。 配列でも 3〜4 節と同じ変換が必要になる。
まとめ
| 症状 | 効く対策 | 節 |
|---|---|---|
| 日付が 1 日ズレる | model-type で文字列固定 (本命) / 送信時に toLocalYMD、 toISOString 禁止 | 3, 4 |
| 422 で送信失敗・型エラー | model-type で文字列固定 / range は配列前提でペイロード設計 | 3, 6 |
| 値が空・null で弾かれる | 送信前に null ガード + 必須なら未選択時にバリデーション | 5 |
| import で undefined / ビルド落ち | 版で波括弧が逆転 (v11=なし / v12+=あり)。 namespace を実機確認 | README |
迷ったら model-type="yyyy-MM-dd" を 1 行足す。 これだけで日付ズレと型不一致の大半が同時に片付く。
設計の勘所
「外部ライブラリの v-model が何を返すか (Date? 文字列? 配列?) を最初に typeof で確かめる」。
返り値の型を把握しないまま送信処理を書くと、 今回のような「昼は通るが深夜で壊れる」 不可視のバグを埋め込む。
ライブラリは便利だが、 境界 (= データが入る/出る所) は自分で型を確認する。
考えてみよう
既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。
datepicker — 演習
Lv1: v-model の中身を観察する
DueDateField.vue を作り、 datepicker で選んだ値の 型と中身 を画面に出す。
import VueDatePicker from '@vuepic/vue-datepicker' // 波括弧なし import '@vuepic/vue-datepicker/dist/main.css' const dueDate = ref(null)
<VueDatePicker v-model="dueDate" locale="ja" />
<p>値: {{ dueDate }}</p>
<p>型: {{ typeof dueDate }}</p>検証
- 日付を選ぶと
値がWed Jun 24 2026 ...のような表示になる 型がobject(= Date オブジェクト) と出る
提出物
「v-model に入っているのは何型か」 を 1 語で答えよ。
Lv2: 壊れるのを再現する
toISOString().slice(0, 10) で日付を切り出す関数を書き、 前日にズレる のを自分で再現する。
function brokenYMD(date) {
return date.toISOString().slice(0, 10) // TODO: これがなぜ壊れるか確認
}検証
| 選ぶ日付 (時刻 0:00) | brokenYMD の出力 |
|---|---|
| 6/24 | 2026-06-23 ← 1 日前にズレる |
> ズレない場合は時刻が昼になっている。 0:00 で選ぶ か、 new Date('2026-06-24T00:00:00+09:00') で試す。
提出物
「なぜ 1 日ズレるのか」 を toISOString と UTC の語を使って 1 文で説明せよ。
Lv3: model-type で文字列固定して直す
model-type="yyyy-MM-dd" を渡し、 v-model が 文字列 になることを確認する。
<!-- TODO: model-type を追加 --> <VueDatePicker v-model="dueDate" model-type="yyyy-MM-dd" locale="ja" />
検証
| 操作 | dueDate の値 | typeof dueDate |
|---|---|---|
| 6/24 を選択 | "2026-06-24" | string |
toISOString を一切使わず、 dueDate をそのまま送れる状態になっていること。
Lv4: toLocalYMD で保険の変換を書く
model-type を 付けない バージョンに戻し、 送信前に安全変換する関数を実装する。
function toLocalYMD(d) {
// TODO 1: d が null なら null を返す
// TODO 2: getFullYear / getMonth(+1) / getDate を padStart で 2 桁に
// TODO 3: `${y}-${m}-${day}` を返す
}検証
| 入力 | 期待 |
|---|---|
new Date('2026-06-24T00:00:00+09:00') | "2026-06-24" (ズレない) |
null | null |
new Date('2026-01-05T00:00:00+09:00') | "2026-01-05" (0 埋め確認) |
提出物
「toISOString ではなく getFullYear 等を使う理由」 を 1 文で書け。
Lv5: null ガードと二重防御
未選択 (null) のまま送信できないようにする。
const canSubmit = computed(() => dueDate.value !== null)
function onSubmit() {
// TODO: null なら return (ロジック側の防御)
console.log('送信', dueDate.value)
}<button :disabled="!canSubmit" @click="onSubmit">登録</button>
検証
- 初期表示: ボタンが無効
- 日付選択後: ボタンが有効、 押すと送信ログが出る
- (DevTools で)
canSubmitを回避してonSubmitを呼んでも、 null では送信処理に進まない
チャレンジ
:range="true"を付け、 v-model が[開始, 終了]の 配列 になることを確認。{ from, to }形式のペイロードを組む- 期限が「今日以降」 でなければ送信不可、 を
computedで追加 (= ch01 のバリデーション作法を日付に適用)
ヒント
- import の波括弧は 版で逆転: v11 (このサンプル) は波括弧なし、 v12+ は波括弧あり。 namespace を実機確認するのが確実。 vue の
import { ref }とも混同しない - CSS (
dist/main.css) を import しないとカレンダーが崩れる getMonth()は 0 始まり (1 月 = 0)。+1を忘れると 1 ヶ月ズレる- 「昼に選ぶと正しく見える」 のが罠。 必ず 0:00 か深夜で検証する