Reactで、Unix時間と日時を変換する入力欄を作る方法

​​React​HTML,JavaScript,Playwright,TypeScript

Unix時間(1970年1月1日 00:00:00 UTC からの経過秒数)と日時の変換は、Date を間に挟めば数行で書けます。つまずきやすいのは日時の欄のほうです。<input type="datetime-local"> の値にはタイムゾーンが付いていないので、どのタイムゾーンの日時として読むかを、コードの側で決める必要があります。

ここでは、Unix時間の欄と日時の欄を並べ、どちらに入力しても、もう一方が書き換わる UnixTimeInput というコンポーネントを作りました。日時の欄は、ブラウザのタイムゾーンの日時として扱います。

補足

検証環境は 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 で操作しました。タイムゾーンは Asia/Tokyo と America/New_York の2つで試しています。Firefox と Safari、スマートフォンの日時の選択画面、スクリーンリーダーでの読み上げは試していません。

白い背景に、「単位」で秒が選ばれ、「Unix時間」の欄に 1793936096、「日時(Asia/Tokyo)」の欄に 2026/11/06 12:34:56 が入っている。その下に「いまの時刻」ボタンと、水色の文字で「UTC:2026-11-06T03:34:56.000Z(時差 +09:00)」が表示されている
Asia/Tokyo のブラウザで、Unix時間 1793936096 を表示したところ

変換の関数

変換の処理は unixtime.ts にまとめました。

ts
// Unix時間と、datetime-local の値(ブラウザのタイムゾーンの日時)を変換する関数

export type Unit = 's' | 'ms';

// この部品で扱う年(1年〜9999年。日時の欄の max 属性に合わせる)に収まるか
const inRange = (date: Date) => date.getFullYear() >= 1 && date.getFullYear() <= 9999;

const pad = (n: number, len = 2) => String(n).padStart(len, '0');

// Date を datetime-local に入れる文字列にする(toISOString は UTC なので使わない)
export function toLocalValue(date: Date, unit: Unit): string {
  const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
  let time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
  if (unit === 'ms') time += `.${pad(date.getMilliseconds(), 3)}`;
  return `${day}T${time}`;
}

// datetime-local の値を Date にする。日付と時刻の形で、時差を書かない文字列は
// その環境のタイムゾーンの日時として読まれる
export function fromLocalValue(value: string): Date | null {
  if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/.test(value)) return null;
  const date = new Date(value);
  return inRange(date) ? date : null;
}

export type UnixResult =
  | { kind: 'empty' }
  | { kind: 'ok'; date: Date; hint?: string }
  | { kind: 'error'; message: string };

// 入力欄の文字列を Unix時間として読む
export function parseUnix(text: string, unit: Unit): UnixResult {
  const s = text.normalize('NFKC').replace(/[\s,_]/g, '').replace(/^[−‐]/, '-');
  if (s === '') return { kind: 'empty' };
  if (!/^-?\d+$/.test(s)) return { kind: 'error', message: '整数で入力してください' };
  const date = new Date(Number(s) * (unit === 's' ? 1000 : 1));
  // いまの時刻は、秒なら10桁、ミリ秒なら13桁
  const digits = s.replace('-', '').length;
  const hint =
    unit === 's' && digits >= 12 ? 'ミリ秒の値かもしれません' :
    unit === 'ms' && digits <= 10 ? '秒の値かもしれません' : undefined;
  if (!inRange(date)) {
    const message = '1年から9999年までの日時になりません';
    return { kind: 'error', message: hint ? `${message}。${hint}` : message };
  }
  return { kind: 'ok', date, hint };
}

export function toUnixText(date: Date, unit: Unit): string {
  const ms = date.getTime();
  return String(unit === 's' ? Math.floor(ms / 1000) : ms);
}

// その日時での、UTC との時差(+09:00 など)
export function offsetText(date: Date): string {
  const m = -date.getTimezoneOffset();
  return `${m < 0 ? '-' : '+'}${pad(Math.floor(Math.abs(m) / 60))}:${pad(Math.abs(m) % 60)}`;
}

日時の欄の値は、ブラウザのタイムゾーンで読む

datetime-local の値は 2026-11-06T12:34 のような文字列で、時差(+09:00 や Z)が付きません。MDN の Date の「日時文字列形式」によると、時差のない文字列のうち、日付だけの形は UTC として、日付と時刻の形は現地時間として読まれます。fromLocalValue は、日付と時刻の形であることを正規表現で確かめてから new Date(value) に渡しています。

検証環境の Chromium(Asia/Tokyo)で試すと、new Date("2026-11-06T00:00") は 2026-11-05T15:00:00.000Z になり、日本時間の0時として読まれました。一方、new Date("2026-11-06") は 2026-11-06T00:00:00.000Z になり、UTC の0時として読まれました。日本時間の0時より9時間遅い時刻です。

逆向きの toLocalValue では、toISOString() を使っていません。toISOString() は、前に挙げた MDN の節にあるとおり UTC の日時を返すので、日本時間で入れたい欄に9時間前の日時が入ります。getFullYear() や getHours() などの現地時間の値を、ゼロ埋めしてつないでいます。

valueAsNumber は Unix時間ではない

入力欄から数値を取るなら valueAsNumber が手軽に見えますが、datetime-local の valueAsNumber は Unix時間としては使えません。HTML の仕様では、この欄の値は時差の情報を持たない日時とされ、数値にすると、1970-01-01T00:00:00.0 から入力された日時までのミリ秒数になります。入力された日時を、タイムゾーンのない日時のまま数えた値です。

検証環境の Chromium(Asia/Tokyo)で 2026-11-06T12:34 を入れると、次のように9時間の差が出ました。

取り方 値 UTC で表すと
valueAsNumber 1793968440000 2026-11-06T12:34:00.000Z
new Date(value).getTime() 1793936040000 2026-11-06T03:34:00.000Z

valueAsNumber は、入力した日時を UTC の日時とみなした値です。日本時間の12時34分の Unix時間は、下の行の値です。

秒とミリ秒を取り違えたときは、桁数でヒントを出す

Unix時間には秒とミリ秒の両方が使われます。JavaScript の Date.now() や getTime() が返すのは、MDN の Date の説明にあるとおりミリ秒単位の値です。単位はラジオボタンで選ぶようにし、桁数が合わないときにヒントを出しました。いまの時刻は、秒なら10桁、ミリ秒なら13桁です。

単位 入力 表示
秒 1793936040 2026-11-06T12:34(Asia/Tokyo)
秒 1793936040000 1年から9999年までの日時になりません。ミリ秒の値かもしれません
ミリ秒 1793936096 1970-01-22T03:18:56.096(ヒント:秒の値かもしれません)
秒 abc 整数で入力してください
秒 1793936040(全角) 2026-11-06T12:34

13桁の値を秒として読むと、9999年を超えるので範囲外のエラーになります。このエラーにも、同じヒントを添えています。全角の数字は normalize('NFKC') で半角にし、桁区切りのカンマや空白は replace で取り除いてから読みます。

HTML の仕様では、日時の欄の年は4桁以上の数字で、1以上であればよく、桁数の上限はありません。このコンポーネントでは、日時の欄の max 属性と fromLocalValue の正規表現で年を4桁(9999年まで)にしているので、Unix時間の側も1年から9999年に合わせました。Date 自体は、同じ MDN の節にあるとおり、1970年から前後1億日(西暦275760年まで)を表せます。

秒にするときは、ミリ秒を1000で割って Math.floor で切り捨てます。MDN の Date の例にも、経過した秒数を得るには Math.round ではなく Math.floor を使う、と書かれています。1970年より前は負の値になり、秒の -1 は、Asia/Tokyo では 1970-01-01T08:59:59 になりました。

入力欄のコンポーネントにまとめる

tsx
import { useId, useState } from 'react';
import { fromLocalValue, offsetText, parseUnix, toLocalValue, toUnixText, type Unit } from './unixtime';

const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

export function UnixTimeInput({ initial = Date.now() }: { initial?: number }) {
  const id = useId();
  const [unit, setUnit] = useState<Unit>('s');
  const [unixText, setUnixText] = useState(() => toUnixText(new Date(initial), 's'));
  const [localText, setLocalText] = useState(() => toLocalValue(new Date(initial), 's'));
  // 日時の欄が読めない(空になった)とき true
  const [localError, setLocalError] = useState(false);

  const unix = parseUnix(unixText, unit);
  // 両方の欄が正しく読めているときだけ、その日時を使う
  const date = !localError && unix.kind === 'ok' ? unix.date : null;

  function changeUnix(text: string) {
    setUnixText(text);
    setLocalError(false);
    const r = parseUnix(text, unit);
    if (r.kind === 'ok') setLocalText(toLocalValue(r.date, unit));
  }

  function changeLocal(value: string) {
    setLocalText(value);
    const d = fromLocalValue(value);
    setLocalError(d === null);
    if (d) setUnixText(toUnixText(d, unit));
  }

  function changeUnit(next: Unit) {
    setUnit(next);
    // 同じ瞬間のまま、表し方だけを変える
    if (date) {
      setUnixText(toUnixText(date, next));
      setLocalText(toLocalValue(date, next));
    }
  }

  function setNow() {
    const now = new Date();
    setUnixText(toUnixText(now, unit));
    setLocalText(toLocalValue(now, unit));
    setLocalError(false);
  }

  return (
    <div className="unixtime">
      <fieldset>
        <legend>単位</legend>
        <label><input type="radio" name={`${id}-unit`} checked={unit === 's'} onChange={() => changeUnit('s')} /> 秒</label>
        <label><input type="radio" name={`${id}-unit`} checked={unit === 'ms'} onChange={() => changeUnit('ms')} /> ミリ秒</label>
      </fieldset>

      <label htmlFor={`${id}-unix`}>Unix時間</label>
      <input
        id={`${id}-unix`}
        inputMode="numeric"
        value={unixText}
        aria-invalid={unix.kind === 'error'}
        aria-describedby={`${id}-unix-msg`}
        onChange={(e) => changeUnix(e.target.value)}
      />
      <p id={`${id}-unix-msg`} className={unix.kind === 'error' ? 'error' : 'hint'}>
        {unix.kind === 'error' ? unix.message : unix.kind === 'ok' ? unix.hint : ''}
      </p>

      <label htmlFor={`${id}-local`}>日時({timeZone})</label>
      <input
        id={`${id}-local`}
        type="datetime-local"
        step={unit === 's' ? 1 : 0.001}
        max="9999-12-31T23:59:59.999"
        value={localText}
        aria-invalid={localError && localText !== ''}
        onChange={(e) => changeLocal(e.target.value)}
      />

      <button type="button" onClick={setNow}>いまの時刻</button>

      <output htmlFor={`${id}-unix ${id}-local`}>
        {date ? `UTC:${date.toISOString()}(時差 ${offsetText(date)})` : ''}
      </output>
    </div>
  );
}
css
.unixtime {
  display: grid;
  gap: 6px;
  max-width: 360px;
}
.unixtime fieldset {
  display: flex;
  gap: 16px;
  border: none;
  padding: 0;
}
.unixtime input:not([type='radio']) {
  font-size: 1.125rem;
  padding: 6px 8px;
}
.unixtime p {
  min-height: 1.5em;
  margin: 0;
}
.unixtime .error {
  color: #d85878;
}
.unixtime output {
  color: #067a98;
  font-family: monospace;
}

2つの欄の文字列を、それぞれ state に持っています。片方を編集したら、読めたときだけもう片方を書き換えます。Unix時間の欄に「abc」と打っても、日時の欄は直前の日時のまま残り、Unix時間の欄の aria-invalid だけが true になりました。下の UTC の行は、両方の欄が読めているときだけ出します。

日時の欄のラベルには、Intl.DateTimeFormat().resolvedOptions().timeZone で取ったタイムゾーンの名前を入れました。同じ Unix時間 1793936040 でも、Asia/Tokyo では 2026-11-06T12:34、America/New_York では 2026-11-05T22:34 と出ます。どこの日時なのかを、画面で分かるようにしています。

日時の欄の step で、秒とミリ秒を出す

datetime-local は、何も指定しなければ分までしか入力できません。MDN の datetime-local の説明にあるとおり、step の単位は秒で、既定は60(1分)です。単位が秒のときは step={1}、ミリ秒のときは step={0.001} にしました。ミリ秒を選んで 1793936096789 と打つと、日時の欄の値は 2026-11-06T12:34:56.789 になりました。

秒が0の時刻は、value から秒が消えます。検証環境の Chromium では、value に 2026-11-06T12:34:00 や 2026-11-06T12:34:00.000 を入れても、読み出すと 2026-11-06T12:34 でした。HTML の仕様で、秒が0なら秒を省いた、いちばん短い形にそろえると決められているためです。そこで、fromLocalValue の正規表現では秒を省略できるようにしました。

年を打っている途中も、値が変わる

日時の欄をキーボードで打つと、1文字ごとに onChange が呼ばれます。検証環境の Chromium で、年の欄に「2」「0」「2」「7」と打つと、値は次のように変わりました。

打った文字 日時の欄の value Unix時間の欄
2 0002-11-06T12:34 -62077351499
0 0020-11-06T12:34 -61509271499
2 0202-11-06T12:34 -55766004299
7 2027-11-06T12:34 1825472040

続けて月の欄で「0」を打つと、月が決まらないので value は空文字になりました。そのときは Unix時間の欄を書き換えず、UTC の行だけを消しています。「3」まで打つと、2027-03-06T12:34 の Unix時間に変わりました。

年が4桁になるまでの途中の値でも、Unix時間の欄が書き換わります。このとき、UTC の行の時差は「+09:18」になりました。tz データベースの asia ファイルでは、Asia/Tokyo は1887年12月31日 15:00(UTC)まで、東京の地方平均時(+9:18:59)とされています。それより前の日時では、+09:00 にはなりません。秒の部分は、この時差の表示には出していません。

夏時間の切り替えでは、入れた日時と Unix時間が1対1にならない

tz データベースでは、Asia/Tokyo の夏時間は1948年から1951年までで、いまの日本には夏時間がありません。ただ、利用者のブラウザのタイムゾーンが、夏時間のある地域になっていることはあります。Playwright でタイムゾーンを America/New_York にして試しました。

2026年3月8日は夏時間が始まる日で、01:59:59 の時差は -05:00、03:30 の時差は -04:00 でした。日時の欄に 02:30 を入れると、Unix時間は 1772955000 になり、03:30 を入れたときと同じ値でした。

日時の欄 Unix時間 UTC の行
2026-03-08T01:59:59 1772953199 2026-03-08T06:59:59.000Z(時差 -05:00)
2026-03-08T02:30 1772955000 2026-03-08T07:30:00.000Z(時差 -04:00)
2026-03-08T03:30 1772955000 2026-03-08T07:30:00.000Z(時差 -04:00)

2026年11月1日は逆に、1時台が2回あります。日時の欄に 01:30 を入れると、1回目(-04:00)の 1793511000 になりました。2回目(-05:00)の 01:30 は、Unix時間の欄に 1793514600 と打てば日時の欄に 01:30 と出ますが、このコンポーネントでは、日時の欄から入れる方法はありません。

Unix時間の欄 日時の欄 UTC の行
1793511000 2026-11-01T01:30 2026-11-01T05:30:00.000Z(時差 -04:00)
1793514600 2026-11-01T01:30 2026-11-01T06:30:00.000Z(時差 -05:00)
1793518200 2026-11-01T02:30 2026-11-01T07:30:00.000Z(時差 -05:00)

どちらも、MDN の Date の説明に書かれた動きのとおりです。現地時間が2つの瞬間に当たるときは早いほうが選ばれ、存在しない時刻は、ずれの分だけ先に進みます。MDN の datetime-local の説明にも、この欄は夏時間の切り替えの1時間のような、その地域では不正な組み合わせも入力できる、とあります。

Node.js で、1970年から2100年までの1時間ごとの Unix時間(1,139,569個)を日時にし、また Unix時間に戻してみました。Asia/Tokyo ではすべて元に戻りました。America/New_York では130個が元に戻らず、どれも、日時が1時台で、戻した値が1時間前になるものでした。秋の切り替えで2回目の1時台に当たる値です。

日時の欄だけを見ていると、この違いに気づけません。コンポーネントの下に UTC と時差を出しておくと、02:30 を入れたのに時差が -04:00 になっていることや、同じ 01:30 でも時差が違うことが、画面で分かります。

​Posted by 86