家計簿アプリを作っていて「昨日の支出として登録したはずなのに、一覧では今日扱いになっている」。日記アプリで「23時台に書いたメモが翌日の日付で保存されている」。これは私自身、Rork で作ったアプリで何度も踏んだことのある罠です。
原因はだいたい2つに集約されます。サーバーから返ってきた 2026-04-29T00:00:00Z のような UTC 文字列をそのまま new Date() した結果、JavaScript が「9時間前のローカル時刻」として解釈してしまうケース。もう1つは、自分で書いた Date オブジェクトを toISOString() で文字列化したつもりが、UTC に変換されていてサーバー側との認識がずれているケースです。
Rork の AI が生成したコードはそれっぽく動いてしまうため、軽くテストした段階では気付きにくいのが厄介です。今回はこの「9時間ずれ問題」を順番に潰していきます。
まずは「ずれ」の正体を切り分ける
タイムゾーン問題に取り掛かる前に、いま何がどうずれているのかをはっきりさせます。これを飛ばすと、無関係なところを修正してさらに混乱します。
最初にやるのは、画面に出ている値とログ出力を並べて比較することです。
// デバッグ用: 一時的にこれを書いて、各ステップで値を確認する
const raw = '2026-04-29T00:00:00Z'; // サーバーから来た値
const parsed = new Date(raw);
console.log('元の文字列:', raw);
console.log('Date オブジェクト:', parsed);
console.log('ローカル表示:', parsed.toString());
console.log('toISOString():', parsed.toISOString());
console.log('toLocaleString JP:', parsed.toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' }));
console.log('getTimezoneOffset:', parsed.getTimezoneOffset());
// 期待: ローカル表示 → 2026/4/29 9:00:00(JST)
// getTimezoneOffset → -540(JST は UTC より9時間進んでいるので負の値)これを iOS シミュレータと実機の両方で走らせます。シミュレータは Mac の設定を継ぐので、実機との差で気付くことが結構あります。「実機だけ8時間ずれる」なら端末のタイムゾーン設定が Asia/Shanghai になっていた、というオチも珍しくありません。
パターン1: UTC 文字列をローカル日付として扱ってしまう
一番多いのがこれです。サーバーは 2026-04-29T00:00:00Z(末尾に Z がついた UTC 表記)を返しているのに、クライアント側で getDate() や getHours() を呼んでしまうと、JST に変換された後の値が返ります。これは「9時間進んだ値」になるので、深夜帯のレコードで日付が1日ずれます。
// ❌ 日付が変わるバグ
const record = { createdAt: '2026-04-29T00:00:00Z' }; // UTC で2026/04/29 00:00
const d = new Date(record.createdAt);
const dayLabel = `${d.getMonth() + 1}/${d.getDate()}`; // → 「4/29」と表示したい
// 実際は JST に変換されて 2026/04/29 09:00 になるので → 「4/29」(たまたま合う)
// だが UTC で 2026/04/28T15:00:00Z を投げた場合は JST だと 2026/04/29 00:00
// → クライアントは「4/29」表示、サーバー DB は「4/28」記録、というズレが起きるサーバー側で「日付の境界」を持つ仕様(家計簿の日次集計、日記の1日1エントリ縛りなど)だと、この種のずれが致命的になります。
解決策は、「日付の意味」を整理して、用途別に書き分けることです。
import { format, parseISO } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
// ✅ 表示用: 必ず JST に変換してからフォーマットする
function formatJstDate(isoString: string): string {
const utc = parseISO(isoString); // UTC として正しく解釈
const jst = toZonedTime(utc, 'Asia/Tokyo'); // JST に明示変換
return format(jst, 'yyyy/MM/dd HH:mm');
}
// 使用例
const label = formatJstDate('2026-04-29T00:00:00Z');
console.log(label); // → "2026/04/29 09:00"date-fns-tz の toZonedTime は、内部的には「UTC の数値はそのままに、表示用フィールドだけ JST 相当にずらした Date」を返します。これに format を組み合わせれば、デバイスのタイムゾーン設定に左右されずに日本時間で出せます。
パターン2: ローカル時刻をそのまま JSON で送ってしまう
逆方向の事故もよく起きます。ユーザーが日付ピッカーで「2026年4月29日」を選んだ Date を、JSON.stringify でそのまま送ると、toISOString() が呼ばれて UTC 文字列になります。サーバー側で「文字列の前半10文字を日付とみなす」実装になっていると、ここで日付がずれます。
// ❌ よくある送信コード
const selectedDate = new Date(2026, 3, 29); // ローカル 2026/04/29 00:00:00 (JST)
const payload = { date: selectedDate };
fetch('/api/diary', { method: 'POST', body: JSON.stringify(payload) });
// 実際に送られる値: { "date": "2026-04-28T15:00:00.000Z" } ← UTC換算で前日になるこれを直すには、「日付として扱いたい値」と「時刻まで含めて扱いたい値」を区別して、前者は文字列で送ります。
import { format } from 'date-fns';
// ✅ 日付として扱う場合: YYYY-MM-DD 文字列で送る
const selectedDate = new Date(2026, 3, 29);
const payload = {
date: format(selectedDate, 'yyyy-MM-dd'), // → "2026-04-29"
};
fetch('/api/diary', { method: 'POST', body: JSON.stringify(payload) });
// ✅ 時刻まで扱う場合: タイムゾーン情報付きの ISO 文字列にする
import { formatISO } from 'date-fns';
const now = new Date();
const payloadWithTime = {
createdAt: formatISO(now), // → "2026-04-29T09:30:15+09:00" (タイムゾーンが明示される)
};タイムゾーン情報を必ず文字列に残しておくのがコツです。これがあれば、後でサーバー側でもクライアント側でも「これは何時何分の出来事か」を一意に再現できます。
パターン3: AsyncStorage に保存した日付が Date ではなく文字列に化ける
ローカル保存系でハマるのがこの罠です。AsyncStorage には文字列しか入らないので、Date を保存して読み戻すと文字列になっています。それを getHours() などで使おうとすると当然エラーで、しかも「たまにエラー」のように見えるので原因究明が遅れます。
import AsyncStorage from '@react-native-async-storage/async-storage';
// 保存
const lastOpened = new Date();
await AsyncStorage.setItem('lastOpened', JSON.stringify(lastOpened));
// JSON.stringify が toISOString を呼ぶので、保存される値は "2026-04-29T00:30:15.000Z"
// 読み込み — ❌ これだと文字列のまま
const raw = await AsyncStorage.getItem('lastOpened');
const value = raw ? JSON.parse(raw) : null;
console.log(value.getHours()); // TypeError: value.getHours is not a function
// ✅ 正しく Date に戻す
const restored = raw ? new Date(JSON.parse(raw)) : null;
console.log(restored?.getHours()); // 正常に動くこの種の問題は AsyncStorage 全般に言えるので、保存処理が絡む別の罠については Rork アプリのデータが消える・保存されない問題の原因と解決策 でも触れています。
パターン4: テストでは合うのに本番で1日ずれる
開発時は問題なかったのに、リリース後にユーザーから「日付がおかしい」とクレームが来るパターンです。原因はだいたい次のどれかです。
- 開発時は JST 端末で確認しており、海外ユーザーの端末タイムゾーンが想定外だった
- iOS シミュレータの「Setting Date & Time」がデフォルトの UTC のままだった
- サマータイム(DST)のある国で、変わり目をまたぐデータが入ってきた
これを未然に防ぐには、開発中から「異なるタイムゾーンの動作」を試す癖をつけます。Mac であれば、ターミナルから一時的にタイムゾーンを変えてシミュレータを起動できます。
# Mac のシミュレータを別タイムゾーンで動かす(例: ハワイ)
TZ='Pacific/Honolulu' npx expo run:ios
# 終わったら通常通りに戻す(再起動でも戻る)
unset TZ私はリリース前に必ず最低3つのタイムゾーン(JST・UTC・PDT)でアプリを動かすようにしています。日付の境界をまたぐテストデータを入れて、表示が崩れないかを目視で確かめます。
パターン5: new Date('2026-04-29') が iOS と Android で違う動きをする
これは細かい仕様差ですが、引っかかるとデバッグに半日かかります。new Date('2026-04-29') のような「ハイフン区切りの日付文字列」を渡したとき、iOS の Hermes と Android の Hermes で UTC として扱うか、ローカル時刻として扱うかが微妙に違うことがあります。
回避策はシンプルで、自前でパースするライブラリを必ず通すことです。
// ❌ 環境差が出る可能性あり
const d1 = new Date('2026-04-29'); // 環境によって挙動が違う
const d2 = new Date('2026/04/29'); // iOS で NaN になることがある
// ✅ date-fns の parse / parseISO を使う
import { parse, parseISO } from 'date-fns';
const d3 = parseISO('2026-04-29'); // ISO 8601 専用
const d4 = parse('2026/04/29', 'yyyy/MM/dd', new Date()); // 任意フォーマットparseISO は ISO 8601 限定で安全に、parse は任意のフォーマットを明示的に指定できます。new Date() の文字列パースに頼らないことが、クロスプラットフォーム開発では事故防止になります。
ライブラリ選びの個人的な指針
私はもう moment.js は使わなくなって、date-fns + date-fns-tz の組み合わせに落ち着いています。理由は3つあります。
- 関数ベースなので、必要なものだけ import すればバンドルが小さくなる
- イミュータブルなので、知らないうちに元の Date が書き換わる事故がない
- TypeScript の型サポートが手厚い
軽量さで dayjs を選ぶ人も多いですが、タイムゾーンプラグインを毎回 import するのが面倒で、私は date-fns のほうが使いやすく感じます。Rork で AI に「日付処理を書いて」と頼むと moment を持ってこようとすることがあるので、そのときは「date-fns を使って」と明示するか、生成後に置き換えるとよいです。
日付・タイムゾーン関連の落とし穴も実例ベースで解説されていて、Rork で生成したコードのレビューにも役立ちます。
全体を振り返って:今日できる予防策を1つだけ
長く書きましたが、もし今すぐできることを1つだけ選ぶなら、プロジェクトに formatJstDate のような薄いラッパー関数を1つ用意して、画面表示はすべてそれを通すことです。
// utils/datetime.ts として配置
import { format, parseISO } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
export function formatJstDate(input: string | Date, pattern = 'yyyy/MM/dd HH:mm'): string {
const utc = typeof input === 'string' ? parseISO(input) : input;
const jst = toZonedTime(utc, 'Asia/Tokyo');
return format(jst, pattern);
}これがあるだけで、新しい画面を作るたびに「あれ、タイムゾーンどう書くんだっけ」と迷うことがなくなります。Rork の AI も、このユーティリティが既に存在することを認識すれば、生成コードの中で自然に呼び出してくれるようになります。
通信周りで詰まったときは Rork アプリの状態がうまく更新されない・再描画されない時の対処法 も参考になるかもしれません。日付処理のバグはときどき再描画タイミングと絡み合って症状を変えるので、合わせて見ておくと原因の切り分けが早くなります。