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

На чтение
7 мин
Дата обновления
05.11.2026

Одна новость может показываться как «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:005 минут назад
Публикация 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 может различаться между реализациями.

Такой подход оставляет в компонентах только отображение. Контракт входа, календарные правила и локализация живут в одном месте, а новую подпись или другой пояс можно проверить без переписывания каждой карточки.