Reactで、日時を「3分前」のように表示する方法

​​React​Intl,JavaScript,Playwright,TypeScript

「3分前」「昨日」のような言葉は、ブラウザに入っている Intl.RelativeTimeFormat が作ってくれます。ライブラリを入れる必要はありません。ただ、分・時間・日のどの単位で出すかは自分で選ぶ必要があります。日数を経過時間から24時間で割って出すと、おとといの夜の投稿が「昨日」になることがあります。

ここでは、単位を選ぶ関数と、時間がたつと「今」→「1分前」→「2分前」と表示を更新するコンポーネントを作ります。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright の時計を操作する機能で、時計を 2026年10月30日 9:00(日本時間)に合わせてから時間を進めました。タイムゾーンは日本時間だけで試しています。StrictMode を付けた開発ビルドでも、出力は本番ビルドと同じで、どちらもコンソールにエラーや警告は出ませんでした。Firefox と Safari、サーバーでの描画(SSR)、タブを裏に回したときのタイマーの動きは試していません。

「コメントする」をクリックすると、一番上に「新しいコメント 今」が増える。時計を早送りすると、投稿から1分00秒で「1分前」、2分00秒で「2分前」、3分00秒で「3分前」に変わり、そのたびに下の「5分前のコメント」も「6分前」「7分前」「8分前」と変わる
コメントを1つ投稿し、時計を早送りした様子。表示が変わった瞬間だけピンクにしている(カーソル、押した瞬間の輪、クリックの回数、経過時間の表示は GIF 用に重ねたもの)

言葉は Intl.RelativeTimeFormat に任せる

Intl.RelativeTimeFormat に言語を渡して作り、format() に数と単位を渡します。過去はマイナスの数で表します。検証環境の Node.js 22 で、日本語('ja')の出力を見ました。

text
-3 minute "3 分前"
-10 second "10 秒前"
0 second "今"
-1 day "昨日"
-2 day "一昨日"
1 day "明日"
-1 week "先週"
-1 year "昨年"

「昨日」「一昨日」のような言い方になるのは、numeric: 'auto' を付けたときです。MDN のコンストラクターの説明のとおり、初期値は 'always' です。付けないと、format(-1, 'day') と format(-2, 'day') は次のように数字で出ます。

text
"1 日前" "2 日前"

もう1つ気をつけたいのが、数字と「分前」の間の空白です。「3 分前」の各文字の文字コード(16進数)を調べると、空白は普通の半角スペース(U+0020)でした。

text
空白の文字コード: 33 20 5206 524d

Chromium 153 で同じように調べても、33 20 5206 524d でした。ただ、format() の説明には、出力は実装によって違うことがあり、ノーブレークスペースが入る場合もあると書かれています。日本語だけを扱うので、この記事のコードでは、空白の種類を問わない \s で空白を取り除いて「3分前」にしています。

どの単位で出すかを選ぶ

format() の単位("minute"、"day" など)は、呼ぶ側が渡します。format(-180, 'second') とすると、「3分前」にはならず、そのまま秒で出ます。

text
"180 秒前"

そこで、日時と現在時刻の差から、単位を選ぶ関数を作ります。

ts
const rtf = new Intl.RelativeTimeFormat('ja', { numeric: 'auto' });
const dateFormat = new Intl.DateTimeFormat('ja', { year: 'numeric', month: 'long', day: 'numeric' });

const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

// Intl は「3 分前」のように数字のあとに空白を入れるので、取り除く
function format(value: number, unit: Intl.RelativeTimeFormatUnit): string {
  return rtf.format(value, unit).replace(/\s/g, '');
}

// その日の0時(端末のタイムゾーン)
function startOfDay(time: number): number {
  const d = new Date(time);
  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}

export function formatRelative(date: Date, now: number): string {
  const diff = date.getTime() - now; // 過去ならマイナス
  const abs = Math.abs(diff);

  if (abs < MINUTE) return format(0, 'second'); // 「今」
  if (abs < HOUR) return format(Math.trunc(diff / MINUTE), 'minute');
  if (abs < DAY) return format(Math.trunc(diff / HOUR), 'hour');

  // 日の単位は、カレンダーの上で何日違うかで数える
  const days = Math.round((startOfDay(date.getTime()) - startOfDay(now)) / DAY);
  if (Math.abs(days) < 7) return format(days, 'day');

  // 1週間より前は、日付で出す
  return dateFormat.format(date);
}

現在時刻を 2026年10月30日 9:00(日本時間)として、いろいろな日時を渡しました。左が渡した日時と現在時刻の差、右が formatRelative の出力です。

text
30秒前 → 今
3分前 → 3分前
59分30秒前 → 59分前
3時間前 → 3時間前
23時間前 → 23時間前
25時間前 → 昨日
34時間前 → 一昨日
4日と21時間前 → 5日前
10日前 → 2026年10月20日
30秒後 → 今
90秒後 → 1分後
27時間後 → 明日

分や時間は Math.round ではなく Math.trunc で切り捨てています。59分30秒前が「1時間前」にならず、「59分前」になります。1分に満たない差は、過去でも未来でも「今」にしました。投稿した直後に、サーバーの時計が端末の時計より少し進んでいて「30秒後」になる、という場合も「今」で済みます。1週間より前は、相対の言い方をやめて日付を出しています。

「昨日」はカレンダーの日付で数える

日の単位だけは、経過時間ではなく、カレンダーの日付の差で数えています。経過時間を24時間で割って日数にすると、どうなるかを試しました。10月28日 23:00 の投稿を、10月30日 9:00 に見た場合です。経過時間は34時間です。

text
24時間で割る   : -1 昨日

34時間を24時間で割ると1.4日で、四捨五入すると1日です。numeric: 'auto' では、1日前は「昨日」になります。しかし、28日は30日から見るとおとといです。上の formatRelative は、両方の日付をその日の0時に直してから差を取るので、「一昨日」と出ます。

0時に直すのは new Date(年, 月, 日) で、端末のタイムゾーンでの0時になります。日本時間の端末で見れば日本の日付で、ほかの国の端末で見ればその国の日付で数えます。この記事で確かめたのは日本時間だけです。

なお、差が24時間より短いときは時間の単位で出すので、10月29日 10:00 の投稿を 30日 9:00 に見ると、「昨日」ではなく「23時間前」になります。

時間がたったら表示を更新する

「今」と出た投稿は、1分たてば「1分前」にしたいところです。formatRelative に渡す現在時刻を state に持ち、useEffect の中で setInterval を使って一定の間隔で更新します。

tsx
import { useEffect, useState } from 'react';
import { formatRelative } from './formatRelative';

const absolute = new Intl.DateTimeFormat('ja', { dateStyle: 'long', timeStyle: 'short' });

// 新しいものほど、こまめに表示を更新する
function updateInterval(age: number): number {
  if (age < 60 * 60 * 1000) return 15 * 1000; // 1時間以内:15秒ごと
  if (age < 24 * 60 * 60 * 1000) return 60 * 1000; // 1日以内:1分ごと
  return 60 * 60 * 1000; // それより前:1時間ごと
}

export function RelativeTime({ date }: { date: Date }) {
  const [now, setNow] = useState(() => Date.now());
  const interval = updateInterval(Math.abs(now - date.getTime()));

  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), interval);
    return () => clearInterval(id);
  }, [interval]);

  return (
    <time dateTime={date.toISOString()} title={absolute.format(date)}>
      {formatRelative(date, now)}
    </time>
  );
}

更新の間隔は、日時の古さで変えています。1時間以内なら表示が分単位で変わるので15秒ごと、1日以内なら1分ごと、それより古いものは1時間ごとです。時間がたって、日時の古さが1時間や1日の区切りを越えると interval の値が変わり、useEffect が古いタイマーを止めて、新しい間隔でタイマーを作り直します。公式リファレンスにある、外部のシステムにつなぐ例のとおり、タイマーはクリーンアップ関数で clearInterval して止めます。

1日以上前の日時は1時間ごとにしか更新しないので、0時を過ぎてから最大で1時間「昨日」が「一昨日」に変わらないことがあります。気になる場合は、この間隔を短くしてください。

表示は <time> 要素で囲み、dateTime 属性に元の日時を入れました。title には「2026年10月30日 8:55」のような日時を入れました。パソコンのブラウザでは、マウスを乗せると出る小さな表示で正確な日時が分かります。ただ、MDN の title 属性の説明にあるとおり、タッチ操作だけの端末やキーボードで操作する人には見えないことが多く、使いにくいとされているので、正確な日時を必ず見せたい場合はほかの方法が要ります。

Chromium で時計を早送りして確かめる

日付をまたぐまで実際に待って確かめるのは難しいので、Playwright の page.clock で時間を進めました。page.clock.install() で時計を 2026年10月30日 9:00 に合わせてからページを開き、page.clock.runFor() で時間を進めます。

コメントの一覧を出すページを開いた直後、5分前のコメントの time 要素は次のように出力されました。datetime は UTC の書き方で、title は日本時間の日時です。

text
<time datetime="2026-10-29T23:55:00.167Z" title="2026年10月30日 8:55">5分前</time>

次に「コメントする」を押し、時計を1秒ずつ進めて、表示が変わったときだけ書き出しました。

text
投稿直後    : 新しいコメント 今
投稿から60秒: 新しいコメント 1分前
投稿から120秒: 新しいコメント 2分前
投稿から180秒: 新しいコメント 3分前

さらに、その日の23:59:30まで進めてから、1時間進めて日付をまたぎました。出力の「30時間前のコメント」が、29日 3:00 の投稿です。

text
23:59:30    : 新しいコメント 14時間前 / 5分前のコメント 15時間前 / 3時間前のコメント 17時間前 / 30時間前のコメント 昨日 / 10日前のコメント 2026年10月20日
翌日 0:59:30: 新しいコメント 15時間前 / 5分前のコメント 16時間前 / 3時間前のコメント 18時間前 / 30時間前のコメント 一昨日 / 10日前のコメント 2026年10月20日

formatRelative は日をその日の0時どうしで比べるので、ページを開いたままでも、0時を過ぎたあとの更新で、29日の投稿の表示は「昨日」から「一昨日」に変わりました。

​Posted by 86