業務システム開発・保守の実務メモを発信中

Nuxt 3で日時表示をcomposableに分けてタイムゾーン対応する

NuxtやVueで日時を表示するとき、最初は画面の中で new Date()toLocaleString() を書いても困りません。1か所だけなら、そこで何をしているかもすぐ分かります。

ただ、一覧、詳細、履歴、通知、ログのように日時表示が増えると、画面ごとに表示形式やfallbackの扱いがずれてきます。

ユーザーごとのタイムゾーンも入ると、さらに読む場所が増えます。

この記事では、Global PocketのNuxtフロントエンド実装を題材に、日時表示を useDateTimeFormatteruseTimezoneOptions のようなcomposableへ分ける考え方を、小さいコードに直して整理します。

tomo

Global Pocketは、筆者が開発しているWebサービスです。
この記事では、そのフロントエンド実装の一部を題材にしています。
サービスの概要は、Global Pocketの紹介ページにまとめていますので気になった方はぜひご覧ください。

目次

画面に直接日時フォーマットを書く形

まずは、画面に直接日時フォーマットを書く形を見ます。たとえば、APIから createdAt が返ってきて、それを画面で表示したいとします。

Vue
<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>

このくらいなら読めますが、問題は、同じような処理を別の画面にも書き始めたあとです

tomo

日時表示は小さく見えますが、一覧やログに増え始めると修正箇所を見失いやすいです。

ある画面では "-" を返し、別の画面では空文字を返す。ある画面では秒まで出し、別の画面では分までしか出さない。ユーザーのタイムゾーンを使いたいのに、画面ごとに timeZone の指定が違う。こうなると、日時表示を直したいだけなのに、いくつもの画面を確認することになります。

日時表示は、画面の主役ではありません。画面側が知りたいのは、だいたい「この日時を表示用の文字列にしたい」だけです。細かい表示形式、timezone、fallbackは、画面から少し離した方が読みやすくなります。

useDateTimeFormatterの入口

Global Pocketでは、日時フォーマットを useDateTimeFormatter.ts に寄せています。画面側は formatDateTime(value) を呼び、細かい整形処理はcomposable側に任せます。

小さい例にすると、入口は次のような形です。

TypeScript
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 のオプション、秒まで出すかどうか、空値を何にするか、不正な日時をどう扱うかを、画面ごとに決めないようにします。

画面側は次のように使えます。

Vue
<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 に落とします。

考え方だけ抜き出すと、次の形です。

TypeScript
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.timezoneAsia/Tokyo のとき、2026-04-14T00:00:00Z2026-04-14 09:00:00 になることを確認しています。UTCの深夜0時が、日本時間の朝9時として表示される形です。

TypeScript
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 に寄せておくと、空値と不正値の扱いをまとめられます。

tomo

fallbackの違いは地味ですが、一覧では横並びで目立ちます。空値の扱いは早めに一か所へ寄せた方が楽です。

TypeScript
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 になることを確認しています。

TypeScript
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 の形に組み立てています。

TypeScript
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配列を使います。

TypeScript
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 に渡すだけです。

Vue
<script setup lang="ts">
// timezone一覧の作り方をフォーム側から隠す。
const { timezones: timezoneOptions } = useTimezoneOptions();
</script>

画面が知るべきなのは、選択肢として使える配列があることです。Intl.supportedValuesOf が使えるかどうか、fallback配列をどう持つかは、timezone選択肢のcomposableに閉じ込めます。

どこまで分けるか

小さい画面で日時が1つだけなら、画面に直接書いても大きな問題にはなりません。分け始める目安は、日時表示のルールが複数画面に出てきたときです。

  • 日時表示が一覧と詳細の両方にある
  • "-"null の扱いをそろえたい
  • ユーザーtimezoneを使いたい
  • 秒まで出すかどうかを一か所で変えたい
  • timezone選択肢を複数フォームで使いたい

このあたりが出てきたら、useDateTimeFormatteruseTimezoneOptions に分けると扱いやすくなります。

日時表示は、目立たないわりに画面のあちこちへ増えます。早めに置き場所を決めておくと、あとから表示形式を直すときに探す量が減ります。

まとめ

Nuxt 3で日時表示が増えてきたら、画面ごとに new Date()Intl.DateTimeFormat を書き続けるより、日時フォーマット用composableへ寄せた方が読みやすくなります。

useDateTimeFormatter には、timezoneの決定、日時の妥当性チェック、fallback、表示形式の組み立てを置きます。画面側は formatDateTime(value) を呼ぶだけにします。

timezone選択肢も useTimezoneOptions に分けておけば、フォーム側は選択肢の配列を受け取るだけで済みます。画面は表示や入力に集中し、日時やtimezoneの細かい扱いはcomposableでそろえる。この分け方にしておくと、日時表示の修正場所が分かりやすくなります。

よかったらシェアしてね!
  • URLをコピーしました!
目次