← Back to all posts

Locale-Aware Date Formatting in React with i18next

July 25, 2023

When you build a global React app, even a seemingly “universal” language like English hides subtleties — especially around dates. “03/04/2021” could mean March 4th or April 3rd, depending on whether your user is in the US, UK, Canada or Australia. To avoid confusion, you need date formatting that adapts not just to “English” in general, but to each user’s locale. In this post we’ll walk through:

  1. A small utility, displayDate, that leverages the browser’s Intl.DateTimeFormat and caches formatters.
  2. How to hook it into your i18next interpolation pipeline for seamless <Trans> and t() usage.
  3. A quick comparison of how the same date appears in different English-speaking locales.

1. A locale-smart displayDate utility

At the heart is a map of preset styles, each with its own Intl.DateTimeFormatOptions and a flag for whether to use the browser’s navigator.language or the I18next language setting:

// src/utils/display-date.ts
import i18next from 'i18next';
import { getDateTime } from './format';

export const DATE_STYLE_OPTIONS = [ /* …list of styles… */ ] as const;
export type DateStyleOptionType = (typeof DATE_STYLE_OPTIONS)[number];

interface DateStyleOptions {
  useNavLang: boolean;
  options: Intl.DateTimeFormatOptions;
}

const DATE_STYLE_MAP: Record<DateStyleOptionType, DateStyleOptions> = {
  default:        { useNavLang: true, options: { year:'numeric', month:'2-digit', day:'2-digit' } },
  'date-time':    { useNavLang: true, options: { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' } },
  'date-medium':  { useNavLang: true, options: { year:'numeric', month:'short', day:'numeric' } },
  // …and the rest…
};

const formatterCache = new Map<string, Intl.DateTimeFormat>();

export function isDateStyleOption(value: string): value is DateStyleOptionType {
  return DATE_STYLE_OPTIONS.includes(value as DateStyleOptionType);
}

export const displayDate = (
  date: Date | number | string | undefined,
  style: DateStyleOptionType
): string => {
  if (!date) return '';
  const { useNavLang, options } = DATE_STYLE_MAP[style];
  const { language } = useNavLang ? navigator : i18next;
  const cacheKey = `${language}-${style}`;

  let formatter = formatterCache.get(cacheKey);
  if (!formatter) {
    formatter = new Intl.DateTimeFormat(language, options);
    formatterCache.set(cacheKey, formatter);
  }

  // getDateTime wraps Date, number or ISO string into e.g. a Luxon DateTime
  return formatter.format(getDateTime(date).toJSDate());
};
  • Why cache? Creating Intl.DateTimeFormat instances can be expensive; caching avoids the churn on every render.
  • Why useNavLang? Sometimes you want to respect the browser’s locale (for default styles), other times you want to format according to your app’s active language in i18next (for content translations).

2. Plugging into i18next interpolation

To make date formatting available directly inside your translation strings, extend i18next’s format hook:

// in your i18next config:
interpolation: {
  escapeValue: false,
  skipOnVariables: false,
  format(value, format) {
    if (!value) return value;
    if (format === 'lowercase') return String(value).toLowerCase();
    if (format === 'uppercase') return String(value).toUpperCase();
    if (format.startsWith('datetime')) {
      const [, style] = format.split('.');
      const dateStyle = isDateStyleOption(style) ? style : 'default';
      return displayDate(value, dateStyle);
    }
    return value;
  },
},

Now any translation can contain something like:

// en.json
{
  "order": "Your order arrives on {{shipDate, datetime.date-medium}} at {{shipDate, datetime.time-only}}."
}

And when you do:

<Trans i18nKey="order" values={{ shipDate: someIsoString }} />

it’ll render fully-localized dates and times.


3. Same date, different formats

Let’s pick one ISO date — 2021-03-31T15:45:00Z — and see how our utility formats it in various English locales using the date-only style (MM/dd/yyyy vs dd/MM/yyyy vs yyyy-MM-dd):

Localenavigator.languageformat = date-only
United States (en-US)en-US03/31/2021
United Kingdom (en-GB)en-GB31/03/2021
Canada (en-CA)en-CA2021-03-31
Australia (en-AU)en-AU31/03/2021
New Zealand (en-NZ)en-NZ31/03/2021

You can switch date-only to date-medium (e.g. “31 Mar 2021” vs “Mar 31, 2021”) or any of the other presets — each locale will pick its own order, separators and month names.


4. Using ranges and custom messages

With interpolation in place, you can even build date ranges in your JSON:

"dates": {
  "range-week": "{{from, datetime.date-only}} – {{to, datetime.date-only}}"
}
t('dates.range-week', {
  from: weekStartIso,
  to: weekEndIso
});
// → "03/29/2021 – 04/04/2021"  (US)
// → "29/03/2021 – 04/04/2021"  (UK)

Or embed inside dialogs:

"dialog": {
  "confirm": "Delete allocation <strong>{{start, datetime.date-only}}–{{end, datetime.date-only}}</strong>?"
}

5. Wrapping up

By combining:

  1. A simple displayDate util with cached Intl.DateTimeFormat formatters
  2. A custom i18next format interceptor
  3. A curated set of date-style options

…you’ll deliver crystal-clear, locale-correct dates to every user — no more “Does 04/05 mean April 5th or May 4th?” questions. Just configure your translation files, sprinkle in {{val, datetime.date-medium}}, and let the platform do the heavy lifting.