はじめに
Next.js App Routerでは、サーバーコンポーネントとクライアントコンポーネントで日時フォーマットを扱う際にハイドレーション不一致が発生しやすくなります。この記事では、国際化対応を含めた効率的なフォーマット管理方法を解説します。
Intl APIによる標準フォーマット
日時フォーマット
// lib/formatters.ts
export function formatDate(
date: Date | string,
locale: string = 'ja-JP',
options?: Intl.DateTimeFormatOptions
): string {
const d = typeof date === 'string' ? new Date(date) : date;
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric',
...options,
};
return new Intl.DateTimeFormat(locale, defaultOptions).format(d);
}
export function formatDateTime(
date: Date | string,
locale: string = 'ja-JP'
): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(d);
}
// ISO 8601形式(API用)
export function toISOString(date: Date): string {
return date.toISOString();
}
// 使用例
formatDate(new Date());
// → "2024年10月24日"
formatDateTime(new Date());
// → "2024年10月24日 14:30"
数値フォーマット
// lib/formatters.ts
export function formatNumber(
value: number,
locale: string = 'ja-JP',
options?: Intl.NumberFormatOptions
): string {
return new Intl.NumberFormat(locale, options).format(value);
}
export function formatCurrency(
value: number,
currency: string = 'JPY',
locale: string = 'ja-JP'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: currency === 'JPY' ? 0 : 2,
maximumFractionDigits: currency === 'JPY' ? 0 : 2,
}).format(value);
}
export function formatPercent(
value: number,
locale: string = 'ja-JP'
): string {
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value);
}
export function formatCompact(
value: number,
locale: string = 'ja-JP'
): string {
return new Intl.NumberFormat(locale, {
notation: 'compact',
compactDisplay: 'short',
}).format(value);
}
// 使用例
formatCurrency(1234567); // → "¥1,234,567"
formatCurrency(99.99, 'USD', 'en-US'); // → "$99.99"
formatPercent(0.156); // → "15.6%"
formatCompact(1234567); // → "123万"
formatNumber(1234567.89); // → "1,234,567.89"
date-fnsによる高度なフォーマット
基本セットアップ
npm install date-fns date-fns-tz
// lib/date-utils.ts
import { format, formatDistance, formatRelative, parseISO } from 'date-fns';
import { ja, enUS } from 'date-fns/locale';
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
const locales: Record<string, Locale> = {
ja,
en: enUS,
};
export function formatDateWithLocale(
date: Date | string,
formatStr: string = 'yyyy年MM月dd日',
locale: string = 'ja'
): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, formatStr, { locale: locales[locale] || ja });
}
// タイムゾーン対応
export function formatWithTimezone(
date: Date | string,
timezone: string,
formatStr: string = 'yyyy/MM/dd HH:mm zzz'
): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return formatInTimeZone(d, timezone, formatStr, { locale: ja });
}
// 相対時間(例:3時間前)
export function formatTimeAgo(
date: Date | string,
locale: string = 'ja'
): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return formatDistance(d, new Date(), {
addSuffix: true,
locale: locales[locale] || ja,
});
}
// 相対日付(例:昨日 14:30)
export function formatRelativeDate(
date: Date | string,
locale: string = 'ja'
): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return formatRelative(d, new Date(), {
locale: locales[locale] || ja,
});
}
// 使用例
formatDateWithLocale(new Date(), 'yyyy年MM月dd日(E)');
// → "2024年10月24日(木)"
formatWithTimezone(new Date(), 'America/New_York', 'yyyy/MM/dd HH:mm zzz');
// → "2024/10/24 01:30 EDT"
formatTimeAgo(new Date(Date.now() - 3 * 60 * 60 * 1000));
// → "約3時間前"
カスタムフォーマット関数
// lib/date-utils.ts
import {
startOfDay,
endOfDay,
startOfMonth,
endOfMonth,
addDays,
differenceInDays,
isToday,
isYesterday,
isTomorrow,
isThisWeek,
isThisMonth,
} from 'date-fns';
// 期間フォーマット
export function formatDateRange(
start: Date,
end: Date,
locale: string = 'ja'
): string {
const sameYear = start.getFullYear() === end.getFullYear();
const sameMonth = sameYear && start.getMonth() === end.getMonth();
if (sameMonth) {
return `${format(start, 'yyyy年M月d日', { locale: locales[locale] })} 〜 ${format(end, 'd日', { locale: locales[locale] })}`;
}
if (sameYear) {
return `${format(start, 'yyyy年M月d日', { locale: locales[locale] })} 〜 ${format(end, 'M月d日', { locale: locales[locale] })}`;
}
return `${formatDateWithLocale(start)} 〜 ${formatDateWithLocale(end)}`;
}
// スマート日付表示
export function formatSmartDate(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date;
if (isToday(d)) {
return `今日 ${format(d, 'HH:mm')}`;
}
if (isYesterday(d)) {
return `昨日 ${format(d, 'HH:mm')}`;
}
if (isTomorrow(d)) {
return `明日 ${format(d, 'HH:mm')}`;
}
if (isThisWeek(d)) {
return format(d, 'E曜日 HH:mm', { locale: ja });
}
if (isThisMonth(d)) {
return format(d, 'M月d日 HH:mm', { locale: ja });
}
return format(d, 'yyyy年M月d日', { locale: ja });
}
// 営業日計算
export function addBusinessDays(date: Date, days: number): Date {
let result = date;
let count = 0;
while (count < days) {
result = addDays(result, 1);
const dayOfWeek = result.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
count++;
}
}
return result;
}
ハイドレーション不一致の解決
問題の原因
サーバーとクライアントでタイムゾーンが異なる場合、日時フォーマットの結果が異なり、ハイドレーション不一致が発生します。
解決方法1: suppressHydrationWarning
// components/FormattedDate.tsx
interface FormattedDateProps {
date: Date | string;
format?: string;
}
export function FormattedDate({ date, format = 'PPP' }: FormattedDateProps) {
return (
<time
dateTime={new Date(date).toISOString()}
suppressHydrationWarning
>
{formatDateWithLocale(date, format)}
</time>
);
}
解決方法2: クライアントサイドのみでフォーマット
// components/ClientFormattedDate.tsx
'use client';
import { useState, useEffect } from 'react';
import { formatSmartDate } from '@/lib/date-utils';
interface Props {
date: Date | string;
fallback?: string;
}
export function ClientFormattedDate({ date, fallback = '...' }: Props) {
const [formatted, setFormatted] = useState<string>(fallback);
useEffect(() => {
setFormatted(formatSmartDate(date));
}, [date]);
return (
<time dateTime={new Date(date).toISOString()}>
{formatted}
</time>
);
}
解決方法3: Server Componentでの統一フォーマット
// components/ServerFormattedDate.tsx
import { formatInTimeZone } from 'date-fns-tz';
interface Props {
date: Date | string;
timezone?: string;
}
// サーバーコンポーネント(デフォルト)
export function ServerFormattedDate({
date,
timezone = 'Asia/Tokyo'
}: Props) {
const d = typeof date === 'string' ? new Date(date) : date;
// 常に指定タイムゾーンでフォーマット
const formatted = formatInTimeZone(
d,
timezone,
'yyyy年MM月dd日 HH:mm',
{ locale: ja }
);
return (
<time dateTime={d.toISOString()}>
{formatted}
</time>
);
}
相対時間のリアルタイム更新
// components/RelativeTime.tsx
'use client';
import { useState, useEffect } from 'react';
import { formatTimeAgo } from '@/lib/date-utils';
interface Props {
date: Date | string;
updateInterval?: number; // ミリ秒
}
export function RelativeTime({ date, updateInterval = 60000 }: Props) {
const [timeAgo, setTimeAgo] = useState<string>('');
useEffect(() => {
const update = () => setTimeAgo(formatTimeAgo(date));
update();
const interval = setInterval(update, updateInterval);
return () => clearInterval(interval);
}, [date, updateInterval]);
const isoDate = typeof date === 'string'
? date
: date.toISOString();
return (
<time dateTime={isoDate} title={formatDateWithLocale(date)}>
{timeAgo || '...'}
</time>
);
}
Intl.RelativeTimeFormat
// lib/relative-time.ts
type Unit = 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second';
const DIVISIONS: { amount: number; name: Unit }[] = [
{ amount: 60, name: 'second' },
{ amount: 60, name: 'minute' },
{ amount: 24, name: 'hour' },
{ amount: 7, name: 'day' },
{ amount: 4.34524, name: 'week' },
{ amount: 12, name: 'month' },
{ amount: Number.POSITIVE_INFINITY, name: 'year' },
];
export function formatRelativeTime(
date: Date | string,
locale: string = 'ja-JP'
): string {
const d = typeof date === 'string' ? new Date(date) : date;
const formatter = new Intl.RelativeTimeFormat(locale, {
numeric: 'auto',
style: 'long',
});
let duration = (d.getTime() - Date.now()) / 1000;
for (const division of DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return formatter.format(Math.round(duration), division.name);
}
duration /= division.amount;
}
return formatter.format(Math.round(duration), 'year');
}
// 使用例
formatRelativeTime(new Date(Date.now() - 30000)); // → "30秒前"
formatRelativeTime(new Date(Date.now() - 3600000)); // → "1時間前"
formatRelativeTime(new Date(Date.now() - 86400000)); // → "昨日"
フォーマッターコンポーネント
統合コンポーネント
// components/Formatters.tsx
'use client';
import { useState, useEffect, useMemo } from 'react';
import { formatCurrency, formatNumber, formatPercent } from '@/lib/formatters';
import { formatSmartDate, formatTimeAgo } from '@/lib/date-utils';
// 通貨表示
export function Currency({
value,
currency = 'JPY',
locale = 'ja-JP',
}: {
value: number;
currency?: string;
locale?: string;
}) {
return <span>{formatCurrency(value, currency, locale)}</span>;
}
// 数値表示
export function Number({
value,
locale = 'ja-JP',
options,
}: {
value: number;
locale?: string;
options?: Intl.NumberFormatOptions;
}) {
return <span>{formatNumber(value, locale, options)}</span>;
}
// パーセント表示
export function Percent({
value,
locale = 'ja-JP',
}: {
value: number;
locale?: string;
}) {
return <span>{formatPercent(value, locale)}</span>;
}
// 日時表示(ハイドレーション対応)
export function DateTime({
value,
format = 'smart',
}: {
value: Date | string;
format?: 'smart' | 'relative' | 'full';
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const formatted = useMemo(() => {
if (!mounted) return '...';
switch (format) {
case 'relative':
return formatTimeAgo(value);
case 'full':
return formatDateWithLocale(value, 'yyyy年MM月dd日 HH:mm:ss');
case 'smart':
default:
return formatSmartDate(value);
}
}, [value, format, mounted]);
const isoDate = typeof value === 'string'
? value
: value.toISOString();
return (
<time dateTime={isoDate} suppressHydrationWarning>
{formatted}
</time>
);
}
使用例
// app/products/[id]/page.tsx
import { Currency, Percent, DateTime } from '@/components/Formatters';
import { RelativeTime } from '@/components/RelativeTime';
interface Product {
id: string;
name: string;
price: number;
discount: number;
createdAt: string;
updatedAt: string;
}
async function getProduct(id: string): Promise<Product> {
const res = await fetch(`${process.env.API_URL}/products/${id}`);
return res.json();
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<article>
<h1>{product.name}</h1>
<dl>
<dt>価格</dt>
<dd>
<Currency value={product.price} />
</dd>
<dt>割引率</dt>
<dd>
<Percent value={product.discount} />
</dd>
<dt>登録日</dt>
<dd>
<DateTime value={product.createdAt} format="full" />
</dd>
<dt>更新</dt>
<dd>
<RelativeTime date={product.updatedAt} />
</dd>
</dl>
</article>
);
}
国際化対応
// lib/i18n-formatters.ts
type SupportedLocale = 'ja' | 'en' | 'zh';
const localeConfig: Record<SupportedLocale, {
dateFormat: string;
currency: string;
timezone: string;
}> = {
ja: {
dateFormat: 'yyyy年MM月dd日',
currency: 'JPY',
timezone: 'Asia/Tokyo',
},
en: {
dateFormat: 'MMMM d, yyyy',
currency: 'USD',
timezone: 'America/New_York',
},
zh: {
dateFormat: 'yyyy年MM月dd日',
currency: 'CNY',
timezone: 'Asia/Shanghai',
},
};
export function createFormatter(locale: SupportedLocale) {
const config = localeConfig[locale];
return {
date: (date: Date | string) =>
formatDateWithLocale(date, config.dateFormat, locale),
currency: (value: number) =>
formatCurrency(value, config.currency, locale),
dateTime: (date: Date | string) =>
formatWithTimezone(date, config.timezone),
};
}
// 使用例
const formatter = createFormatter('ja');
formatter.date(new Date()); // → "2024年10月24日"
formatter.currency(1000); // → "¥1,000"
まとめ
| 用途 | 推奨方法 |
|---|---|
| 基本フォーマット | Intl API |
| 複雑な日付操作 | date-fns + date-fns-tz |
| 相対時間 | Intl.RelativeTimeFormat |
| ハイドレーション対応 | suppressHydrationWarning or useEffect |
| タイムゾーン対応 | formatInTimeZone |