React Router v7での相対時間のフォーマット方法

タイムスタンプを「2日前」のような表現にフォーマットする

問題

「2日前」や「3時間後」のような相対的な時間表現でタイムスタンプを表示すると、コンテンツが即時的かつ文脈的に感じられます。しかし、これらのフレーズは言語によって大きく異なる複雑な文法規則に従います。英語では過去の時間表現に「ago」を後置し、未来の時間表現に「in」を前置しますが、他の言語では時間単位そのものを活用させたり、語順を変えたり、まったく異なる文法構造を使用したりする場合があります。文字列連結を手動で構築すると、ハードコードした言語以外のすべての言語で不正確な出力が生成され、国際的なユーザーにとってのユーザーエクスペリエンスが損なわれます。

解決策

ロケール対応の相対時間フォーマットを使用して、タイムスタンプの差分を文法的に正確なフレーズに変換します。指定されたタイムスタンプと現在の時刻との時間差を計算し、ユーザーのロケールルールを使用してその差分をフォーマットします。これにより、手動での文字列操作なしに、過去と未来の時間表現が各言語の正しい文法、語順、活用パターンに従うことが保証されます。

ステップ

1. 相対時間値を計算するヘルパーを作成する

FormattedRelativeTimeコンポーネントには数値のvalueunitプロップが必要です。時間差を計算し、適切な単位を選択するヘルパー関数を構築します。

export function getRelativeTimeValue(date: Date | number) {
  const now = Date.now();
  const timestamp = typeof date === "number" ? date : date.getTime();
  const diffInSeconds = Math.round((timestamp - now) / 1000);

  const minute = 60;
  const hour = minute * 60;
  const day = hour * 24;
  const week = day * 7;
  const month = day * 30;
  const year = day * 365;

  const absDiff = Math.abs(diffInSeconds);

  if (absDiff < minute) {
    return { value: diffInSeconds, unit: "second" as const };
  } else if (absDiff < hour) {
    return {
      value: Math.round(diffInSeconds / minute),
      unit: "minute" as const,
    };
  } else if (absDiff < day) {
    return { value: Math.round(diffInSeconds / hour), unit: "hour" as const };
  } else if (absDiff < week) {
    return { value: Math.round(diffInSeconds / day), unit: "day" as const };
  } else if (absDiff < month) {
    return { value: Math.round(diffInSeconds / week), unit: "week" as const };
  } else if (absDiff < year) {
    return { value: Math.round(diffInSeconds / month), unit: "month" as const };
  } else {
    return { value: Math.round(diffInSeconds / year), unit: "year" as const };
  }
}

この関数は、タイムスタンプを符号付き数値と、差分の大きさに基づいて最も適切な時間単位に変換します。

2. FormattedRelativeTimeを使用して相対時間コンポーネントを作成する

react-intlのFormattedRelativeTimeコンポーネントを使用します。これはフォーマットされた相対時間をレンダリングし、オプションで一定間隔で更新することができます。

import { FormattedRelativeTime } from "react-intl";
import { getRelativeTimeValue } from "./getRelativeTimeValue";

interface RelativeTimeProps {
  date: Date | number;
  updateIntervalInSeconds?: number;
}

export function RelativeTime({
  date,
  updateIntervalInSeconds,
}: RelativeTimeProps) {
  const { value, unit } = getRelativeTimeValue(date);

  return (
    <FormattedRelativeTime
      value={value}
      unit={unit}
      numeric="auto"
      updateIntervalInSeconds={updateIntervalInSeconds}
    />
  );
}

numeric="auto"オプションを使用すると、適切な場合にフォーマッタが「1日前」ではなく「昨日」のようなフレーズを使用できるようになります。オプションのupdateIntervalInSecondsプロパティは、相対時間を最新の状態に保つためにコンポーネントが再レンダリングする頻度を制御します。

3. React Routerルートでコンポーネントを使用する

タイムスタンプを表示する必要があるルートコンポーネントで相対時間コンポーネントをレンダリングします。

import { RelativeTime } from "./RelativeTime";

interface Post {
  id: string;
  title: string;
  content: string;
  createdAt: number;
}

export function PostDetail({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <time dateTime={new Date(post.createdAt).toISOString()}>
        <RelativeTime date={post.createdAt} />
      </time>
      <p>{post.content}</p>
    </article>
  );
}

このコンポーネントは、ユーザーのロケールに応じてタイムスタンプを自動的にフォーマットし、英語では「2 days ago」、フランス語では「il y a 2 jours」、スペイン語では「hace 2 días」などのフレーズを生成します。

4. useIntlを使用して命令的に相対時間をフォーマットする

フォーマットされた文字列を直接必要とする場合(要素の属性を設定するなど)、useIntlフックからformatRelativeTime関数を使用します。

import { useIntl } from "react-intl";
import { getRelativeTimeValue } from "./getRelativeTimeValue";

interface CommentProps {
  author: string;
  text: string;
  timestamp: number;
}

export function Comment({ author, text, timestamp }: CommentProps) {
  const intl = useIntl();
  const { value, unit } = getRelativeTimeValue(timestamp);
  const relativeTime = intl.formatRelativeTime(value, unit, {
    numeric: "auto",
  });

  return (
    <div aria-label={`Comment by ${author}, posted ${relativeTime}`}>
      <strong>{author}</strong>
      <p>{text}</p>
    </div>
  );
}

このアプローチでは、属性で使用したり、他のテキストと連結したり、完全なロケールサポートを維持しながら非ReactのAPIに渡したりできるフォーマット済みの文字列が得られます。