Автоматическое форматирование дат публикации в новостных карточках на React

Одна новость может показываться как «5 ноября» на сервере и «6 ноября» в браузере, хотя исходная запись не менялась. Обычно проблема не в React, а в неявном часовом поясе, неоднозначном формате входа или вычислении «сейчас» в разные моменты. Надёжное решение начинается с договора о данных.
Сделаем авторский пример новостных карточек с русской локалью и редакционным часовым поясом Europe/Moscow. Этот пояс выбран для примера, а не объявлен правильным для любого сайта. Порог «только что», формат минут и переход к календарным подписям — решения продукта, которые можно изменить отдельно от компонентов.
Храните момент публикации, а не готовую русскую подпись
Для обмена между backend и frontend удобно явно согласовать ISO-дату со временем и зоной либо числовой timestamp. В JavaScript Date числовое значение означает миллисекунды, поэтому секунды из другого API требуют отдельного преобразования. Строка «05.11.2026 10:30» не является надёжным универсальным форматом для Date.parse.
В этом примере контракт намеренно строже общего ISO: API передаёт каноническую UTC-строку вида 2026-11-05T21:00:00.000Z. Z означает UTC. Такая строка обозначает момент времени; локальная подпись вычисляется отдельно. Если ваш backend отдаёт корректный ISO со смещением +03:00, приведите его к принятому контракту на границе данных или измените валидатор осознанно.
Наш parsePublishedAt проверяет успешное создание Date и точное обратное преобразование через toISOString. Поэтому он отклонит не только мусор, но и неканонические входы, включая дату без времени. Он также не пропустит нормализованную движком несуществующую дату вроде 30 февраля под видом исходной записи.
Один форматтер для списка
Для единичного значения подходит date.toLocaleDateString('ru-RU', options). Для серии одинаково оформленных карточек удобно создать Intl.DateTimeFormat один раз и вызывать format. MDN отмечает возможность повторного использования форматтера вместо многократного поиска настроек локализации.
Не собирайте русское название месяца вручную. Укажите локаль, нужные части и timeZone. Локаль определяет языковое оформление, а пояс — календарные дату и время выбранного момента. Русский язык сам по себе не означает московский пояс.
Ниже — полный файл функций. Он не зависит от React и потому проверяется отдельно. Относительные минуты формирует Intl.RelativeTimeFormat; слова «сегодня» и «вчера» выбираются по календарным дням именно редакционного пояса.
const timeZone = 'Europe/Moscow';
const absolute = new Intl.DateTimeFormat('ru-RU', {
timeZone, year: 'numeric', month: 'long', day: 'numeric',
});
const clock = new Intl.DateTimeFormat('ru-RU', {
timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
const calendar = new Intl.DateTimeFormat('en-CA', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
});
const relative = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'always' });
export function parsePublishedAt(value) {
if (typeof value !== 'string') return null;
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return null;
return date.toISOString() === value ? date : null;
}
function dayNumber(date) {
const p = Object.fromEntries(calendar.formatToParts(date)
.filter(x => x.type !== 'literal').map(x => [x.type, x.value]));
const day = new Date(0);
day.setUTCFullYear(Number(p.year), Number(p.month) - 1, Number(p.day));
return day.getTime() / 86_400_000;
}
export function formatPublication(value, nowIso) {
const date = parsePublishedAt(value);
if (!date) return 'Дата не указана';
const now = parsePublishedAt(nowIso);
if (!now) return absolute.format(date);
const age = now.getTime() - date.getTime();
const days = dayNumber(date) - dayNumber(now);
if (age < 0) return absolute.format(date);
if (days === 0 && age < 60_000) return 'только что';
if (days === 0 && age < 3_600_000) {
return relative.format(-Math.floor(age / 60_000), 'minute');
}
if (days === 0) return `сегодня в ${clock.format(date)}`;
if (days === -1) return `вчера в ${clock.format(date)}`;
return absolute.format(date);
}
Почему «вчера» — не просто 24 часа назад
В авторском правиле возраст менее минуты внутри текущего дня обозначается «только что». До часа в том же дне выводятся целые прошедшие минуты. Более раннее событие текущего дня получает часы; предыдущего календарного дня — слово «вчера»; остальные — абсолютную дату.
Сначала проверяется будущая публикация: она не превращается в отрицательное число минут «назад». Это вариант отображения уже имеющихся данных, а не функция планирования публикации. Если продукт вообще не должен показывать будущие новости, отбор выполняется отдельной логикой.
dayNumber извлекает календарные части в заданном поясе и сравнивает номера дней. Поэтому логика календарной границы отделена от длины прошедшего интервала. Не заменяйте её проверкой age < 86400000: близкие моменты по разные стороны полуночи относятся к разным дням.
| Условие при now = 6 ноября, 00:05 по Москве | Результат правил |
|---|---|
| Публикация 6 ноября, 00:00 | 5 минут назад |
| Публикация 5 ноября, 23:59 | вчера в 23:59 |
| Публикация 6 ноября, 00:04:40 | только что |
| Момент в будущем | Абсолютная дата |
| Неверный вход | Дата не указана |
Это намеренный выбор: запись шестиминутной давности через полночь считается вчерашней. Если редакции нужны минуты независимо от границы дня, поменяйте порядок ветвей и соответствующие проверки. У универсального Intl.RelativeTimeFormat нет готовой бизнес-логики именно вашего новостного блока.
Подключите функции к компонентам
PublishedAt получает исходную строку и единый снимок nowIso. Элемент time хранит машиночитаемый момент в dateTime, а внутри показывает подпись. Для некорректной даты используется обычный span без ложного datetime. Ключ списка берётся из устойчивого id, как рекомендует руководство React.
import { parsePublishedAt, formatPublication } from './news-date-utils.mjs';
function PublishedAt({ value, nowIso }) {
if (!parsePublishedAt(value)) return <span>Дата не указана</span>;
return <time dateTime={value}>{formatPublication(value, nowIso)}</time>;
}
export function NewsList({ items, nowIso }) {
return items.map(item => (
<article key={item.id}>
<h2>{item.title}</h2>
<PublishedAt value={item.publishedAt} nowIso={nowIso} />
</article>
));
}Компонент получает items и nowIso от родителя. При изменении props React пересчитает подпись. Само течение времени не вызывает новый render: если карточки должны обновлять «минуты назад» на открытой странице, родитель отдельно обновляет nowIso, например по своему таймеру, с очисткой при размонтировании. В показанном коде таймер не реализован.
Что проверить при SSR
React требует совпадения первоначального клиентского содержимого с серверным при гидратации. Не вычисляйте отдельный new Date() в каждой карточке на сервере и снова в браузере: на границе минуты или дня подписи могут разойтись.
Передайте один и тот же nowIso с серверными данными и используйте одинаковые locale и timeZone для первого вывода. Учитывайте также доступность и различия данных Intl в средах исполнения. Если нужен гарантированно одинаковый текст, передайте первоначальную готовую подпись с сервера, а клиентский пересчёт включайте после гидратации.
Отключение предупреждения о несоответствии не устраняет причину. Сначала сравните исходную строку, пояс, момент now и алгоритм обеих сторон. Отдельно проверьте, не получил ли браузер date-only вместо полного времени публикации.
Проверенный пример и границы проверки
Файл функций проверен в Node.js на границе полуночи Europe/Moscow, минутной подписи, будущем моменте, неверной строке, пустом значении, отсутствии зоны и несуществующей календарной дате. Случай недоступного now также проверен: остаётся абсолютная дата, если сама публикация корректна.
Это проверка функций, не выполненный здесь запуск React-приложения или SSR-сервера. После интеграции дополнительно сравните реальную серверную разметку и первый клиентский render, проверьте обновление таймера и браузеры проекта. Не привязывайте проверки только к одному пробелу или знаку пунктуации локализованной даты: оформление Intl может различаться между реализациями.
Такой подход оставляет в компонентах только отображение. Контракт входа, календарные правила и локализация живут в одном месте, а новую подпись или другой пояс можно проверить без переписывания каждой карточки.



