TL;DR
-
Date.prototype.toISOString()は常にUTCを返す -
<input type="datetime-local">はタイムゾーン情報を持たないローカル時刻文字列(YYYY-MM-DDTHH:mm)を期待する - この2つを組み合わせると、JST環境では編集画面が9時間ズレる
- 解決策は「表示JST / 保存UTC / 変換は1関数に集約」
環境
- フロント: React 18 + TypeScript 5
- 入力UI:
<input type="datetime-local"> - バックエンド: Node.js 20 / PostgreSQL(
timestamptz) - タイムゾーン: サーバーUTC固定、クライアントはブラウザロケール(主にJST)
発生した症状
予約投稿機能を持つ管理ダッシュボードで、次のような挙動に遭遇しました。
- 一覧画面: 投稿予定時刻が
2026-04-20 12:00と表示される - 編集画面: 同じレコードを開くと
2026-04-20 03:00にセットされている - DB:
timestamptzで2026-04-20 03:00:00+00が保存されている
DB値は正しい(JST 12:00 = UTC 03:00)。壊れていたのは編集画面の初期値セット処理だけでした。
原因: toISOString() × datetime-local の相性
問題のコードはこうでした。
// ❌ NG: 9時間ズレる
const initialValue = new Date(record.scheduledAt).toISOString().slice(0, 16);
// → "2026-04-20T03:00" (UTC)
toISOString() は仕様上常に Z(UTC)を付与して返す。一方 datetime-local の value 属性は「ユーザーのローカルタイムゾーンにおける時刻」を文字列として扱うため、UTC文字列をそのまま突っ込むと、ユーザーから見ると9時間ズレて見える。
ブラウザは value の文字列をそのまま表示します。タイムゾーン変換はしてくれません。ここが最大の落とし穴です。
修正: JST文字列を直接作る
// ✅ OK: JSTに寄せてから datetime-local 文字列を作る
function toDatetimeLocalJST(iso: string): string {
const d = new Date(iso);
// UTC基準の値を +9h してから、UTCメソッドで読む
const jst = new Date(d.getTime() + 9 * 60 * 60 * 1000);
const yyyy = jst.getUTCFullYear();
const mm = String(jst.getUTCMonth() + 1).padStart(2, "0");
const dd = String(jst.getUTCDate()).padStart(2, "0");
const hh = String(jst.getUTCHours()).padStart(2, "0");
const mi = String(jst.getUTCMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
}
ポイントは 「ズラしてからUTCメソッドで読む」。getHours() などローカル系APIを使うと、ブラウザのロケール依存になってテストがブレます。
保存時は逆変換します。
// datetime-local の文字列(JST前提)→ UTC ISO にして送信
function fromDatetimeLocalJST(value: string): string {
// "2026-04-20T12:00" を JST 扱いでUTCに
return new Date(`${value}:00+09:00`).toISOString();
}
+09:00 をサフィックスで付けることで、ブラウザロケールに関係なくJST解釈になります。
表示・保存・変換のルール
チーム規約として以下を徹底すると再発しません。
| レイヤ | タイムゾーン | 型 |
|---|---|---|
| DB | UTC | timestamptz |
| API (送受信) | UTC | ISO 8601 with Z
|
| UI表示 | JST | YYYY-MM-DD HH:mm |
| 変換関数 | 1ファイルに集約 | utils/datetime.ts |
変換関数を1箇所にまとめるのが最重要です。コンポーネントごとに new Date(...).toISOString() のような処理を書き散らかすと、今回のような事故が必ず起きます。
なぜ開発環境で気づきにくいのか
- 開発者のローカルタイムゾーンがJST
-
Date.prototype.toString()はローカルタイムゾーンで表示する - なので
console.log(new Date(value))してもそれっぽく見える - ブラウザの
datetime-local初期値も「ズレてるけど一応それっぽい時刻」として表示される
JSTとUTCが9時間差なので「昼の予定が朝にズレる」程度で済み、目視チェックでスルーされやすい。DSTがある地域(英国など)だと夏冬で挙動が変わり、さらに厄介です。
テスト観点
予約投稿系では最低でも以下は自動テストにしておきたいです。
- JST 0:30(UTCでは前日15:30)を編集画面で開いて「00:30」と表示されるか
- 日付をまたぐ時刻の保存・再読み込みで値が変わらないか
-
TZ=UTC node testのようにサーバー側のタイムゾーンを固定してテストする
CIがUTCコンテナで動くのに、ローカルテストだけJSTで通っている、という状態だと本番で事故ります。
まとめ
-
toISOString()は便利だが、UI層の入力値生成に使うとタイムゾーン事故の原因になる -
datetime-localinput はタイムゾーン情報を持たないリテラル文字列として扱う - 「表示JST / 保存UTC / 変換1関数」を守る
- 開発環境のタイムゾーンをCIと揃える or テストで
TZ=UTCを固定する
地味な領域ですが、予約系・通知系のサービスでは一発でユーザーの信頼を失うバグになります。早めに規約化しておくのがおすすめです。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中