NuxtやVueで日時を表示するとき、最初は画面の中で new Date() や toLocaleString() を書いても困りません。1か所だけなら、そこで何をしているかもすぐ分かります。
ただ、一覧、詳細、履歴、通知、ログのように日時表示が増えると、画面ごとに表示形式やfallbackの扱いがずれてきます。
ユーザーごとのタイムゾーンも入ると、さらに読む場所が増えます。
この記事では、Global PocketのNuxtフロントエンド実装を題材に、日時表示を useDateTimeFormatter と useTimezoneOptions のようなcomposableへ分ける考え方を、小さいコードに直して整理します。
tomoGlobal Pocketは、筆者が開発しているWebサービスです。
この記事では、そのフロントエンド実装の一部を題材にしています。
サービスの概要は、Global Pocketの紹介ページにまとめていますので気になった方はぜひご覧ください。
画面に直接日時フォーマットを書く形
まずは、画面に直接日時フォーマットを書く形を見ます。たとえば、APIから createdAt が返ってきて、それを画面で表示したいとします。
<script setup lang="ts">
const formattedCreatedAt = computed(() => {
// 値がないときは、画面に出す代替表示をここで決める。
if (!props.createdAt) {
return "-";
}
const date = new Date(props.createdAt);
// 不正な日時文字列をそのまま表示処理へ流さない。
if (Number.isNaN(date.getTime())) {
return "-";
}
return new Intl.DateTimeFormat("ja-JP", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
});
</script>このくらいなら読めますが、問題は、同じような処理を別の画面にも書き始めたあとです。



日時表示は小さく見えますが、一覧やログに増え始めると修正箇所を見失いやすいです。
ある画面では "-" を返し、別の画面では空文字を返す。ある画面では秒まで出し、別の画面では分までしか出さない。ユーザーのタイムゾーンを使いたいのに、画面ごとに timeZone の指定が違う。こうなると、日時表示を直したいだけなのに、いくつもの画面を確認することになります。
日時表示は、画面の主役ではありません。画面側が知りたいのは、だいたい「この日時を表示用の文字列にしたい」だけです。細かい表示形式、timezone、fallbackは、画面から少し離した方が読みやすくなります。
useDateTimeFormatterの入口
Global Pocketでは、日時フォーマットを useDateTimeFormatter.ts に寄せています。画面側は formatDateTime(value) を呼び、細かい整形処理はcomposable側に任せます。
小さい例にすると、入口は次のような形です。
const defaultTimezone = "Asia/Tokyo";
export const useDateTimeFormatter = () => {
const timezone = computed(() => defaultTimezone);
const formatDateTime = (value?: string | null, fallback = "-") => {
// 空値の扱いを一か所で決める。
if (!value) {
return fallback;
}
const date = new Date(value);
// 不正な日時も、画面ごとに判断させず同じfallbackへ寄せる。
if (Number.isNaN(date.getTime())) {
return fallback;
}
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone.value,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(date);
const partValue = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? "";
return `${partValue("year")}-${partValue("month")}-${partValue("day")} ${partValue("hour")}:${partValue("minute")}:${partValue("second")}`;
};
return {
timezone,
formatDateTime,
};
};ここで分けたいのは、日時の表示ルールです。Intl.DateTimeFormat のオプション、秒まで出すかどうか、空値を何にするか、不正な日時をどう扱うかを、画面ごとに決めないようにします。
画面側は次のように使えます。
<template>
<span>{{ formatDateTime(record.occurred_at) }}</span>
</template>
<script setup lang="ts">
// 画面は日時フォーマットの細かい設定を知らず、この関数だけを使う。
const { formatDateTime } = useDateTimeFormatter();
</script>Global Pocketでも、監査ログ、通知処理、システムエラーログ、ファイルやノートの更新日時などで formatDateTime() を呼んでいます。画面側には、テーブル、モーダル、一覧更新などの処理が残り、日時の整形ルールはcomposable側に寄っています。
ユーザーのtimezone
実務の画面では、固定のタイムゾーンだけで十分とは限りません。ユーザーごとにtimezoneを持っている場合、表示時にその設定を使いたくなります。
Global Pocketの useDateTimeFormatter.ts は、useState("app-bootstrap") から現在ユーザーのtimezoneを読みます。設定が空なら Asia/Tokyo に落とします。
考え方だけ抜き出すと、次の形です。
const defaultTimezone = "Asia/Tokyo";
export const useDateTimeFormatter = () => {
const bootstrapState = useState<{
currentUser?: {
timezone?: string | null;
} | null;
}>("app-bootstrap", () => ({}));
const timezone = computed(() => {
// ユーザー設定が空なら、アプリ側の既定値に戻す。
return bootstrapState.value.currentUser?.timezone?.trim() || defaultTimezone;
});
return {
timezone,
};
};画面ごとに現在ユーザーのtimezoneを読みに行くと、画面がアプリ全体の状態を知りすぎます。日時フォーマット用composableがtimezoneを決めれば、画面側は「どのtimezoneで表示するか」を毎回考えなくて済みます。
既存テストでは、currentUser.timezone が Asia/Tokyo のとき、2026-04-14T00:00:00Z が 2026-04-14 09:00:00 になることを確認しています。UTCの深夜0時が、日本時間の朝9時として表示される形です。
expect(formatter.timezone.value).toBe("Asia/Tokyo");
expect(formatter.formatDateTime("2026-04-14T00:00:00Z")).toBe("2026-04-14 09:00:00");このテストで見たいのは、ブラウザの日付仕様そのものではありません。アプリがユーザーのtimezoneを読み、同じ関数から同じ表示形式を返していることです。
fallbackの扱い
日時表示では、値がない場合もあります。APIの項目が null のこともあれば、まだ完了していない処理の completed_at が空のこともあります。不正な日時文字列が入ってくる可能性もあります。
こうした扱いを画面ごとに書くと、表示がずれます。useDateTimeFormatter に寄せておくと、空値と不正値の扱いをまとめられます。



fallbackの違いは地味ですが、一覧では横並びで目立ちます。空値の扱いは早めに一か所へ寄せた方が楽です。
const formatDateTime = (value?: string | null, fallback = "-") => {
// 未設定の日時は、呼び出し側が指定したfallbackで返す。
if (!value) {
return fallback;
}
const date = new Date(value);
// Dateとして扱えない値も、画面へそのまま出さない。
if (Number.isNaN(date.getTime())) {
return fallback;
}
return "2026-04-14 09:00:00";
};fallbackを引数にしているので、呼び出し側は必要に応じて表示を変えられます。通常は "-" でよい。値がないこと自体を区別したい場所では null を返す。Global Pocketの既存テストでも、formatDateTime("invalid-date") は "-"、formatDateTime(null, null) は null になることを確認しています。
expect(formatter.formatDateTime("invalid-date")).toBe("-");
expect(formatter.formatDateTime(null, null)).toBeNull();ここを一か所に寄せると、画面側の条件分岐が減ります。画面は「表示するかどうか」「どこに置くか」を扱い、日時として正しいかどうかの判定はcomposableに任せます。
formatToPartsで表示を揃える
Intl.DateTimeFormat は便利ですが、単に format(date) を使うと、localeや環境によって表示の形が変わることがあります。Global Pocketでは、formatToParts() で必要な部品を取り出し、YYYY-MM-DD HH:mm:ss の形に組み立てています。
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone.value,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const parts = formatter.formatToParts(date);
const partValue = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? "";
// 表示形式をアプリ側で固定し、画面ごとの揺れを避ける。
return `${partValue("year")}-${partValue("month")}-${partValue("day")} ${partValue("hour")}:${partValue("minute")}:${partValue("second")}`;読み始めの段階では、formatToParts() が少し大げさに見えるかもしれません。狙いは単純です。年、月、日、時、分、秒を部品として取り出し、アプリで決めた順番に並べています。
この処理を各画面に置くと、表示形式を変えたいときに全画面を探すことになります。composableに寄せておけば、秒を消したい、表示形式を変えたい、といった修正の入口が決まります。
timezoneの選択肢
ユーザーにtimezoneを選ばせる画面では、選択肢も必要です。ここでも、画面にブラウザAPIの扱いを直接書かず、useTimezoneOptions に分けます。
Global Pocketの useTimezoneOptions.ts は、Intl.supportedValuesOf("timeZone") が使える環境ならそれを使います。使えない環境では、あらかじめ用意したfallback配列を使います。
const fallbackTimezones = [
"Asia/Tokyo",
"Europe/London",
"America/New_York",
"America/Los_Angeles",
];
export const useTimezoneOptions = () => {
const timezones = computed(() => {
// ブラウザAPIが使える環境では、対応timezone一覧をそのまま使う。
const supported = typeof Intl.supportedValuesOf === "function"
? Intl.supportedValuesOf("timeZone")
: fallbackTimezones;
// Selectに渡しやすい形へ揃える。
return supported.map((timezone) => ({
label: timezone,
value: timezone,
}));
});
return {
timezones,
};
};画面側は、Selectの options に渡すだけです。
<script setup lang="ts">
// timezone一覧の作り方をフォーム側から隠す。
const { timezones: timezoneOptions } = useTimezoneOptions();
</script>画面が知るべきなのは、選択肢として使える配列があることです。Intl.supportedValuesOf が使えるかどうか、fallback配列をどう持つかは、timezone選択肢のcomposableに閉じ込めます。
どこまで分けるか
小さい画面で日時が1つだけなら、画面に直接書いても大きな問題にはなりません。分け始める目安は、日時表示のルールが複数画面に出てきたときです。
- 日時表示が一覧と詳細の両方にある
"-"やnullの扱いをそろえたい- ユーザーtimezoneを使いたい
- 秒まで出すかどうかを一か所で変えたい
- timezone選択肢を複数フォームで使いたい
このあたりが出てきたら、useDateTimeFormatter や useTimezoneOptions に分けると扱いやすくなります。
日時表示は、目立たないわりに画面のあちこちへ増えます。早めに置き場所を決めておくと、あとから表示形式を直すときに探す量が減ります。
まとめ
Nuxt 3で日時表示が増えてきたら、画面ごとに new Date() や Intl.DateTimeFormat を書き続けるより、日時フォーマット用composableへ寄せた方が読みやすくなります。
useDateTimeFormatter には、timezoneの決定、日時の妥当性チェック、fallback、表示形式の組み立てを置きます。画面側は formatDateTime(value) を呼ぶだけにします。
timezone選択肢も useTimezoneOptions に分けておけば、フォーム側は選択肢の配列を受け取るだけで済みます。画面は表示や入力に集中し、日時やtimezoneの細かい扱いはcomposableでそろえる。この分け方にしておくと、日時表示の修正場所が分かりやすくなります。



