Reactで、世界の都市の時刻を並べて表示する方法

​​React​Intl.DateTimeFormat,JavaScript,Playwright,TypeScript

都市ごとの時刻は、Intl.DateTimeFormat の timeZone にタイムゾーン名('America/New_York' など)を渡すだけで出せます。時差の計算や夏時間の切り替えを自分で書く必要はありません。時刻は親で1つだけ持ち、どの都市も同じ瞬間から表示を作ると、秒がずれずにそろいます。

ここでは、都市ごとに時刻・日付・時差を並べ、都市を足したり消したりできる WorldClock という部品を作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。閲覧者のタイムゾーンは Playwright の timezoneId で、今の時刻は page.clock で決めています。Firefox と Safari、スマートフォンの実機、サーバーでの描画は試していません(サーバーとブラウザで時刻の表示がずれる問題は、「Reactで日付を表示するとき、サーバーとブラウザのずれを防ぐ方法」の記事で扱っています)。

東京、ニューヨーク、ロンドン、デリー、シドニー、Los Angeles、Katmandu の7行。それぞれ都市名、タイムゾーンの日本語名と UTC からのずれ、大きな水色の時刻、日付、東京との時差が並び、Los Angeles の行だけ日付が11月4日(水)で「前日」と表示されている
東京で2026年11月5日15時4分5秒に開き、2都市を足した画面

都市ごとの時刻は、timeZone を指定して書式にする

時刻・日付・時差を求める関数を1つのファイルにまとめました。

ts
// タイムゾーンごとの時刻・日付・時差を求める関数

type Formatters = {
  time: Intl.DateTimeFormat;
  date: Intl.DateTimeFormat;
  offset: Intl.DateTimeFormat;
  name: Intl.DateTimeFormat;
};

// Intl.DateTimeFormat は作るのに時間がかかるので、タイムゾーンごとに1回だけ作る
const cache = new Map<string, Formatters>();

function formattersFor(timeZone: string): Formatters {
  let f = cache.get(timeZone);
  if (!f) {
    f = {
      time: new Intl.DateTimeFormat('ja-JP', {
        timeZone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
      }),
      date: new Intl.DateTimeFormat('ja-JP', { timeZone, month: 'short', day: 'numeric', weekday: 'short' }),
      offset: new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' }),
      name: new Intl.DateTimeFormat('ja-JP', { timeZone, timeZoneName: 'longGeneric' }),
    };
    cache.set(timeZone, f);
  }
  return f;
}

// タイムゾーンの名前として使える文字列か
export function isValidTimeZone(timeZone: string): boolean {
  try {
    new Intl.DateTimeFormat('en-US', { timeZone });
    return true;
  } catch {
    return false; // 使えない名前は RangeError になる
  }
}

// ブラウザが内部で使う名前。別名(Asia/Kolkata と Asia/Calcutta など)を比べるときに使う
export function resolvedZone(timeZone: string): string {
  return new Intl.DateTimeFormat('en-US', { timeZone }).resolvedOptions().timeZone;
}

// formatToParts の結果から、type が合う部分の文字を取り出す
function part(f: Intl.DateTimeFormat, date: Date, type: Intl.DateTimeFormatPartTypes): string {
  return f.formatToParts(date).find((p) => p.type === type)?.value ?? '';
}

// その時点の UTC からのずれ(分)。"GMT+05:30" → 330、"GMT-04:00" → -240、"GMT" → 0
export function offsetMinutes(timeZone: string, date: Date): number {
  const m = part(formattersFor(timeZone).offset, date, 'timeZoneName').match(/([+-])(\d{2}):(\d{2})/);
  if (!m) return 0;
  const minutes = Number(m[2]) * 60 + Number(m[3]);
  return m[1] === '-' ? -minutes : minutes;
}

// 分を時と分に分ける。-330 → { sign: '−', h: 5, m: 30 }
function split(minutes: number) {
  const abs = Math.abs(minutes);
  return { sign: minutes < 0 ? '−' : '+', h: Math.floor(abs / 60), m: abs % 60 };
}

// その地域の暦で、1970-01-01 から何日目か
function dayNumber(date: Date, offset: number): number {
  return Math.floor((date.getTime() + offset * 60_000) / 86_400_000);
}

export type ZoneTime = {
  time: string; // "15:04:05"
  date: string; // "11月5日(木)"
  zoneName: string; // "米国東部時間"。名前がなく "GMT+00:00" のような形になるときは ""
  utcOffset: string; // "UTC−5"、"UTC+5:30"
  diff: string; // 基準の地域との時差。"−14時間"、"−3時間30分"、同じなら ""
  dayShift: -1 | 0 | 1; // 基準の地域から見て 前日 / 同じ日 / 翌日
};

export function zoneTime(timeZone: string, now: Date, baseZone: string): ZoneTime {
  const f = formattersFor(timeZone);
  const name = part(f.name, now, 'timeZoneName');
  const offset = offsetMinutes(timeZone, now);
  const baseOffset = offsetMinutes(baseZone, now);
  const o = split(offset);
  const d = split(offset - baseOffset);
  return {
    time: f.time.format(now),
    date: f.date.format(now),
    zoneName: name.startsWith('GMT') ? '' : name,
    utcOffset: offset === 0 ? 'UTC' : `UTC${o.sign}${o.h}${o.m ? `:${String(o.m).padStart(2, '0')}` : ''}`,
    diff: offset === baseOffset ? '' : `${d.sign}${d.h ? `${d.h}時間` : ''}${d.m ? `${d.m}分` : ''}`,
    dayShift: Math.sign(dayNumber(now, offset) - dayNumber(now, baseOffset)) as -1 | 0 | 1,
  };
}

Intl.DateTimeFormat() コンストラクターの説明のとおり、timeZone には IANA のタイムゾーン名を渡します。Date オブジェクトは、地域によらない「ある瞬間」を表します。そのため、同じ Date を timeZone だけ変えて書式にすれば、その地域の時刻になります。日本時間の2026年11月5日15時に開くと、表示は次のとおりでした。

text
東京 | 日本標準時(UTC+9) | 15:00:00 | 11月5日(木)
ニューヨーク | 米国東部時間(UTC−5) | 01:00:00 | 11月5日(木)−14時間
ロンドン | イギリス時間(UTC) | 06:00:00 | 11月5日(木)−9時間
デリー | インド標準時(UTC+5:30) | 11:30:00 | 11月5日(木)−3時間30分
シドニー | オーストラリア東部時間(UTC+11) | 17:00:00 | 11月5日(木)+2時間

「米国東部時間」のような名前は、timeZoneName: 'longGeneric' で書式にした結果から取り出しています。言語に 'ja-JP' を指定しているので、日本語の名前になりました。名前の文字列を自分で用意する必要はありません。

UTC だけは、日本語の名前が出ずに GMT+00:00 になりました。Intl.DateTimeFormat() の timeZoneName の説明にも、名前の文字列がないときは別の形で表示されることがあると書かれています。そこで、GMT で始まるときは名前を出さず、UTC からのずれだけを出しています。

UTC からのずれは longOffset から読む

時差を数えるには、それぞれの地域が UTC からどれだけずれているかが必要です。timeZoneName: 'longOffset' で書式にすると、formatToParts() の timeZoneName の部分が GMT+05:30 や GMT-05:00 の形になります。offsetMinutes は、これを分に直しています。検証環境では、次の値になりました。

タイムゾーン longOffset の文字 offsetMinutes
Asia/Kolkata(インド) GMT+05:30 330
Asia/Kathmandu(ネパール) GMT+05:45 345
America/New_York GMT-05:00 -300
UTC GMT+00:00 0

時差は1時間単位とは限りません。インドは30分、ネパールは45分ずれています。shortOffset だと GMT-5 や GMT+5:30 のように、時の桁数や分の有無がそろわないので、いつも +hh:mm の形になる longOffset を使いました。日本語の書式でも、longOffset の部分は GMT+05:30 で同じでした。念のため、GMT だけの文字が来たときは、正規表現に合わずに 0 を返すようにしています。

前日・翌日は、それぞれの地域の日付で比べる

時差だけでは、日付が変わっているかどうかは分かりません。東京が朝のとき、ニューヨークは前の日の夕方です。dayNumber は、UTC からのずれを足した時刻を1日の長さで割り、その地域の暦で「1970年1月1日から何日目か」を出します。基準の地域と比べて1日先なら「翌日」、1日前なら「前日」です。

基準の地域は、閲覧している人のタイムゾーンです。Intl.DateTimeFormat().resolvedOptions().timeZone で取っています。閲覧者のタイムゾーンを America/New_York にして、現地の11月5日20時30分に開くと、次のようになりました。

text
東京 | 日本標準時(UTC+9) | 10:30:00 | 11月6日(金)翌日+14時間
ニューヨーク | 米国東部時間(UTC−5) | 20:30:00 | 11月5日(木)
ロンドン | イギリス時間(UTC) | 01:30:00 | 11月6日(金)翌日+5時間
デリー | インド標準時(UTC+5:30) | 07:00:00 | 11月6日(金)翌日+10時間30分
シドニー | オーストラリア東部時間(UTC+11) | 12:30:00 | 11月6日(金)翌日+16時間

時差の符号も、ニューヨークから見た向きに変わっています。

夏時間の切り替わりも、Intl に任せれば時差が自動で変わる

ずれは毎回、その瞬間の Date から求めています。そのため、夏時間の始まりや終わりで時差が変わっても、コードで扱う必要はありません。ニューヨークの夏時間が終わる2026年11月1日の、切り替わりの2秒前から1秒ずつ進めると、ニューヨークの行は次のように変わりました。

text
2026-11-01T05:59:59.000Z  ニューヨーク | 米国東部時間(UTC−4) | 01:59:59 | 11月1日(日)−13時間
2026-11-01T06:00:00.000Z  ニューヨーク | 米国東部時間(UTC−5) | 01:00:00 | 11月1日(日)−14時間

現地の時刻は1時59分59秒の次が1時0分0秒に戻り、東京との時差は13時間から14時間になりました。もし時差を「ニューヨークは −14時間」のように固定で持つと、夏の間は1時間ずれた時刻を出すことになります。

Intl.DateTimeFormat は、タイムゾーンごとに1回だけ作る

formattersFor は、作った Intl.DateTimeFormat をタイムゾーンごとに Map に取っておき、2回目からはそれを使います。MDN の toLocaleString() の説明にも、同じ引数で何度も書式にするなら、Intl.DateTimeFormat を作ってその format() を使うほうがよいと書かれています。

検証環境で、同じ時刻を1万回書式にする時間を、3回ずつ測りました。

書き方 1万回にかかった時間
毎回 new Intl.DateTimeFormat() で作る 3,074〜4,135ms
毎回 toLocaleTimeString() を呼ぶ 3,396〜4,038ms
作っておいた1つで format() 17〜33ms

毎回作ると、1回あたり約0.3〜0.4ms かかりました。この部品は、都市ごとに4種類の書式(時刻・日付・ずれ・名前)を使います。毎回作り直す書き方だと、都市が7つなら1秒ごとに少なくとも28個を作ることになり、約9〜12ms かかる計算です。

時刻は親で1つだけ持ち、秒の変わり目で更新する

今の時刻は、カスタムフックの useNow で持ちます。

ts
import { useEffect, useState } from 'react';

// 秒が変わるごとに、今の時刻を返す
export function useNow(): Date {
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    let id = 0;
    const tick = () => {
      const d = new Date();
      setNow(d);
      id = window.setTimeout(tick, 1000 - d.getMilliseconds());
    };
    tick();
    return () => clearTimeout(id);
  }, []);
  return now;
}

秒の変わり目まで待ってから更新するこの書き方は、「Reactで、針が動くアナログ時計を作る方法」の記事で、setInterval で1秒ごとに更新する書き方と比べています。

部品の本体です。useNow は WorldClock で1回だけ呼び、同じ now をすべての行に渡しています。

tsx
import { useId, useMemo, useState } from 'react';
import { useNow } from './useNow';
import { isValidTimeZone, resolvedZone, zoneTime } from './zoneTime';

export type City = { label: string; timeZone: string };

type Props = { initialCities: City[] };

// 閲覧している人のタイムゾーン(時差を数える基準)
const baseZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

export function WorldClock({ initialCities }: Props) {
  const now = useNow(); // 全部の都市で、1つの時刻を使う
  const [cities, setCities] = useState(() =>
    initialCities.filter((c) => isValidTimeZone(c.timeZone)),
  );

  return (
    <section className="world-clock">
      <ul>
        {cities.map((c) => (
          <ClockRow
            key={c.timeZone}
            city={c}
            now={now}
            onRemove={() => setCities((list) => list.filter((x) => x.timeZone !== c.timeZone))}
          />
        ))}
      </ul>
      <AddCity
        onAdd={(timeZone) =>
          setCities((list) =>
            // 別名で同じ地域がもう並んでいたら、足さない
            list.some((x) => resolvedZone(x.timeZone) === resolvedZone(timeZone))
              ? list
              : [...list, { label: timeZone.split('/').pop()!.replaceAll('_', ' '), timeZone }],
          )
        }
      />
    </section>
  );
}

function ClockRow({ city, now, onRemove }: { city: City; now: Date; onRemove: () => void }) {
  const t = zoneTime(city.timeZone, now, baseZone);
  return (
    <li>
      <div className="city">
        <strong>{city.label}</strong>
        <small>{t.zoneName ? `${t.zoneName}(${t.utcOffset})` : t.utcOffset}</small>
      </div>
      <time className="time" dateTime={now.toISOString()}>
        {t.time}
      </time>
      <div className="date">
        {t.date}
        {t.dayShift === -1 && <span className="shift">前日</span>}
        {t.dayShift === 1 && <span className="shift">翌日</span>}
        {t.diff && <small>{t.diff}</small>}
      </div>
      <button type="button" onClick={onRemove} aria-label={`${city.label}を消す`}>
        ×
      </button>
    </li>
  );
}

function AddCity({ onAdd }: { onAdd: (timeZone: string) => void }) {
  const id = useId();
  const [value, setValue] = useState('');
  // Intl.supportedValuesOf の一覧には "UTC" が入っていないので、先頭に足す
  const zones = useMemo(() => ['UTC', ...Intl.supportedValuesOf('timeZone')], []);
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        if (value) onAdd(value);
        setValue('');
      }}
    >
      <label htmlFor={id}>都市を足す</label>
      <select id={id} value={value} onChange={(e) => setValue(e.target.value)}>
        <option value="">選んでください</option>
        {zones.map((z) => (
          <option key={z} value={z}>
            {z}
          </option>
        ))}
      </select>
      <button type="submit" disabled={!value}>
        追加
      </button>
    </form>
  );
}

行ごとに useNow を呼ぶと、タイマーも行の数だけ動きます。親で1つにしておけば、どの行も同じ瞬間の時刻を書式にします。ブラウザの時計を15時0分0.400秒で止めて開き、少しずつ進めると、5つの行は同時に変わりました。

text
+599ms 進めた後: 2026-11-05T06:00:00.999Z 時刻の欄 15:00:00, 01:00:00, 06:00:00, 11:30:00, 17:00:00
+1ms 進めた後: 2026-11-05T06:00:01.000Z 時刻の欄 15:00:01, 01:00:01, 06:00:01, 11:30:01, 17:00:01
css
.world-clock ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
.world-clock li {
  display: grid;
  grid-template-columns: 1fr auto auto;
  grid-template-areas:
    'city time remove'
    'city date remove';
  align-items: center;
  gap: 0 12px;
  padding: 10px 0;
  border-bottom: 1px solid #d5eef4;
}
.world-clock .city { grid-area: city; }
.world-clock .city small,
.world-clock .date small {
  display: block;
  color: #5b6b73;
}
.world-clock .time {
  grid-area: time;
  font-size: 1.75rem;
  font-variant-numeric: tabular-nums;
  color: #067a98;
  text-align: right;
}
.world-clock .date { grid-area: date; text-align: right; }
.world-clock .shift {
  margin-left: 6px;
  padding: 0 6px;
  border-radius: 4px;
  background: #eefafd;
  color: #067a98;
}
.world-clock li button { grid-area: remove; }
.world-clock form {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin-top: 12px;
}

時刻の数字には font-variant-numeric: tabular-nums を付けています。MDN の font-variant-numeric の説明のとおり、数字がすべて同じ幅になるので、秒が変わっても時刻の幅が揺れません。

使えないタイムゾーン名は、RangeError になる前に外す

timeZone に存在しない名前を渡すと、Intl.DateTimeFormat を作る時点でエラーになります。検証環境で 'Asia/Osaka' を渡すと、RangeError: Invalid time zone specified: Asia/Osaka になりました。

isValidTimeZone は、実際に作ってみてエラーになるかどうかで判断します。結果は次のとおりでした。

渡した文字 結果
Asia/Tokyo 使える
asia/tokyo 使える(大文字と小文字は区別しない)
UTC 使える
+05:30 使える
Asia/Osaka 使えない
Tokyo 使えない

確認用のページでは、最初の都市の一覧に「大阪」として Asia/Osaka を入れておきました。useState の初期値で isValidTimeZone を通しているので、大阪の行だけが出ず、ほかの5都市は表示されました。

都市を選ぶ一覧には、古い名前が出ることがある

都市を足す select の選択肢は、Intl.supportedValuesOf() で取っています。検証環境の Chromium では、この一覧に UTC が入っていなかったので、先頭に足しました。選択肢の419件のうち、418件が一覧から、1件が UTC です。

一覧には、Asia/Kolkata ではなく Asia/Calcutta、Asia/Kathmandu ではなく Asia/Katmandu が入っていました。どちらも、IANA のタイムゾーンデータベースの backward ファイルで、今の名前への別名として残されている古い名前です。resolvedOptions().timeZone も、Asia/Kolkata を渡すと Asia/Calcutta、Europe/Kyiv を渡すと Europe/Kiev を返しました。MDN の supportedValuesOf() の説明では、Temporal の標準化によって、一覧には Asia/Calcutta ではなく Asia/Kolkata が入るとされていますが、検証環境の Chromium 153 はそうなっていませんでした。

そのため、行に出す地域の名前は、ID ではなく longGeneric の日本語の名前にしています。Asia/Katmandu を足しても、都市名の下の小さな文字には「ネパール時間」と出ます。追加した都市の太字の名前は、ID の最後の部分(Katmandu)をそのまま使っています。

同じ地域を2回足さないように、resolvedZone で比べています。最初から Asia/Kolkata のデリーが並んでいるところに、一覧から Asia/Calcutta を選んで「追加」を押しても、行は増えませんでした。ただし、MDN の resolvedOptions() の説明には、Temporal の標準化により、ブラウザは指定された名前を別名に置き換えずに返すことになると書かれています。この比べ方で別名の重複を防げるのは、検証環境の Chromium のように、resolvedOptions() が今の名前を古い名前にそろえて返すときです。

選択肢を選んで「追加」を押すと、行は1つずつ増え、もう並んでいる America/Los_Angeles を選んだときは増えませんでした。「×」のボタンで行を消すこともできます。操作の間、コンソールには React DevTools の案内が1行出ただけで、エラーや警告は出ませんでした。

​Posted by 86