Temporal API: Replace Moment.js and date-fns 2026
The Temporal API reached Stage 4 at the March 2026 TC39 meeting. It's native in Chrome 144+, Firefox 139+, and available behind a flag in Node.js v24. The 30-year wait for a correct JavaScript date library is over — and the API was worth the wait.
Moment.js is still downloaded 13 million times a week. date-fns gets 20 million. Day.js — the lightweight Moment.js successor — gets 40 million. Most of that code is doing things Temporal now handles natively, without a 67KB library or a functional import chain. This article covers what Temporal replaces, what it doesn't, and how to migrate.
TL;DR
Temporal replaces Moment.js and date-fns for most use cases — date arithmetic, time zones, duration calculation, calendar math. It does NOT replace relative time formatting ("2 hours ago") or locale-aware display strings. Use temporal-polyfill (~20KB) for new projects today; @js-temporal/polyfill (~44KB) for the full reference implementation. Node.js native Temporal (no flag) is not yet available — use a polyfill for Node.js services.
Key Takeaways
- Temporal.PlainDate / PlainDateTime: immutable dates without time zones — use for most business logic
- Temporal.ZonedDateTime: the correct type for calendar events, scheduling, user-facing times
- Temporal.Instant: a point in time (like
Date.now()but correct) - Temporal.Duration: date/time arithmetic results —
d1.until(d2)returns a Duration - Immutable by default: all methods return new objects — no
.clone()needed temporal-polyfill: ~20KB gzipped, preferred for production (smaller, near-perfect spec compliance)@js-temporal/polyfill: ~44KB gzipped, full reference implementation, passes full TC39 test suite- Migration path: Moment → Temporal is straightforward; date-fns → Temporal is mostly 1:1 with different syntax
At a Glance
| Capability | Moment.js | date-fns v4 | Temporal |
|---|---|---|---|
| Bundle size | 67KB (full) | ~2KB/fn tree-shaken | 0KB (native) / ~20KB polyfill |
| Immutability | ❌ (mutable) | ✅ | ✅ |
| Time zone support | Via moment-timezone (+60KB) | Via @date-fns/tz | Native |
| Calendar systems | ❌ | ❌ | ✅ (ISO, Japanese, Hebrew, etc.) |
| DST handling | ❌ (bugs) | Partial | ✅ correct |
| Duration type | ❌ | ❌ | ✅ Temporal.Duration |
| Type of date | Date wrapper | Date functions | New types |
| TypeScript | Poor | Excellent | Excellent |
| Browser support (native) | Any | Any | Chrome 144+, FF 139+ |
| Maintenance | ❌ (legacy mode) | ✅ active | ✅ TC39 spec |
The Problem with Date
Before diving into Temporal, it's worth understanding why two generations of date libraries exist. JavaScript's Date object was copied from Java in 10 days in 1995 and has been broken since:
// Month is 0-indexed — January is 0, December is 11
new Date(2026, 0, 1) // January 1, 2026
new Date(2026, 11, 31) // December 31, 2026 — not month 11!
// Date is mutable — this is a trap
const d = new Date('2026-01-01')
d.setMonth(d.getMonth() + 1) // mutates d in place
console.log(d) // 2026-02-01 — but you might not expect d changed
// DST math is broken
const dt = new Date('2026-11-01T01:30:00')
dt.setHours(dt.getHours() + 1)
// During a DST fallback, this can give you the wrong answer
// Parsing is implementation-specific
new Date('2026-3-1') // works in V8, may fail elsewhere
new Date('March 1, 2026') // works in browsers, inconsistent in Node
Moment.js fixed the API surface but not the underlying model (still uses Date internally, still mutable by default). date-fns is immutable but doesn't have its own date type — it operates on native Date objects and can't represent concepts like "duration" or "time-zone-aware datetime."
Temporal fixes the model.
The Temporal Type System
Temporal's key insight is that "date" and "time" are different concepts that need different types:
Temporal.PlainDate — just a date: 2026-03-16 (no time, no timezone)
Temporal.PlainTime — just a time: 14:30:00 (no date, no timezone)
Temporal.PlainDateTime — date + time: 2026-03-16T14:30:00 (no timezone)
Temporal.ZonedDateTime — date + time + timezone: 2026-03-16T14:30:00[America/New_York]
Temporal.Instant — exact point in time (epoch nanoseconds)
Temporal.Duration — a span of time: P1Y2M3DT4H5M6S
Temporal.PlainYearMonth — just year + month: 2026-03
Temporal.PlainMonthDay — just month + day: --03-16 (for recurring events)
Choosing the right type prevents entire classes of bugs:
// Bad: using Date for a birthdate (timezone corrupts it)
const birthday = new Date('1990-05-15') // stored as UTC, displayed in local timezone
// Good: PlainDate has no timezone — it's always May 15, 1990
const birthday = Temporal.PlainDate.from('1990-05-15')
// Bad: scheduling a meeting without timezone info
const meeting = new Date('2026-06-01T14:00:00') // what timezone??
// Good: ZonedDateTime is unambiguous
const meeting = Temporal.ZonedDateTime.from({
year: 2026, month: 6, day: 1, hour: 14,
timeZone: 'America/New_York'
})
Core Operations: Temporal vs Moment.js vs date-fns
Creating Dates
// Current date/time
// Moment
const now = moment()
// date-fns (no special constructor — uses native Date)
const now = new Date()
// Temporal
const now = Temporal.Now.plainDateTimeISO() // PlainDateTime
const today = Temporal.Now.plainDateISO() // PlainDate only
const instant = Temporal.Now.instant() // Exact point in time (UTC)
// From a string
// Moment
moment('2026-03-16')
moment('2026-03-16T14:30:00')
// date-fns
parseISO('2026-03-16')
parseISO('2026-03-16T14:30:00')
// Temporal
Temporal.PlainDate.from('2026-03-16')
Temporal.PlainDateTime.from('2026-03-16T14:30:00')
Temporal.ZonedDateTime.from('2026-03-16T14:30:00[America/New_York]')
Temporal.Instant.from('2026-03-16T14:30:00Z') // Z = UTC
// From parts
// Moment
moment({ year: 2026, month: 2, day: 16 }) // month is 0-indexed!
// date-fns
new Date(2026, 2, 16) // month is 0-indexed!
// Temporal — months are 1-indexed, like a human
Temporal.PlainDate.from({ year: 2026, month: 3, day: 16 })
Arithmetic
// Add 30 days
// Moment (mutates!)
moment().add(30, 'days') // modifies the moment object
// date-fns
addDays(new Date(), 30) // returns new Date
// Temporal (always returns new object)
Temporal.Now.plainDateISO().add({ days: 30 })
// Subtract 1 month
// Moment
moment('2026-03-31').subtract(1, 'month') // → 2026-02-28 (may not be what you want)
// date-fns
subMonths(new Date('2026-03-31'), 1) // → 2026-02-28
// Temporal — same result
Temporal.PlainDate.from('2026-03-31').subtract({ months: 1 }) // → 2026-02-28
// Add 1 year and 2 months and 15 days
// Moment
moment().add(1, 'year').add(2, 'months').add(15, 'days')
// date-fns
addDays(addMonths(addYears(new Date(), 1), 2), 15) // nested
// Temporal — single operation, correct calendar arithmetic
Temporal.Now.plainDateISO().add({ years: 1, months: 2, days: 15 })
Comparison
// Is date A before date B?
// Moment
moment('2026-01-01').isBefore(moment('2026-06-01')) // true
// date-fns
isBefore(new Date('2026-01-01'), new Date('2026-06-01')) // true
// Temporal
Temporal.PlainDate.compare(
Temporal.PlainDate.from('2026-01-01'),
Temporal.PlainDate.from('2026-06-01')
) < 0 // true
// Or use .since() for more info
const d1 = Temporal.PlainDate.from('2026-01-01')
const d2 = Temporal.PlainDate.from('2026-06-01')
d1.until(d2) // Temporal.Duration { months: 5, days: 0 }
Duration Between Dates
// How many days between two dates?
// Moment
moment('2026-06-01').diff(moment('2026-01-01'), 'days') // 151
// date-fns
differenceInDays(new Date('2026-06-01'), new Date('2026-01-01')) // 151
// Temporal — returns a Duration object, not just a number
const d1 = Temporal.PlainDate.from('2026-01-01')
const d2 = Temporal.PlainDate.from('2026-06-01')
const duration = d1.until(d2) // Temporal.Duration
duration.days // 0 (months are not converted to days by default)
d1.until(d2, { largestUnit: 'day' }).days // 151
// Or get a rich "1 year, 2 months, 3 days" breakdown
d1.until(d2, { largestUnit: 'year' })
// Temporal.Duration { months: 5, days: 0 }
Time Zones
This is where Temporal's advantage over Moment.js is most dramatic:
// Current time in Tokyo
// Moment (requires moment-timezone)
moment().tz('Asia/Tokyo').format('HH:mm')
// date-fns (requires @date-fns/tz)
format(toZonedTime(new Date(), 'Asia/Tokyo'), 'HH:mm', {
timeZone: 'Asia/Tokyo'
})
// Temporal (native, no extra package)
Temporal.Now.zonedDateTimeISO('Asia/Tokyo').toLocaleString('ja-JP', {
hour: '2-digit', minute: '2-digit'
})
// Convert between time zones
const nyMeeting = Temporal.ZonedDateTime.from({
year: 2026, month: 6, day: 1,
hour: 14, minute: 0,
timeZone: 'America/New_York'
})
const tokyoTime = nyMeeting.withTimeZone('Asia/Tokyo')
console.log(tokyoTime.hour) // 3 (next day, +14 offset)
Formatting
Temporal integrates with Intl.DateTimeFormat but doesn't have its own format syntax (no "YYYY-MM-DD" strings):
const date = Temporal.PlainDate.from('2026-03-16')
// Built-in .toLocaleString()
date.toLocaleString('en-US', { dateStyle: 'full' })
// "Monday, March 16, 2026"
date.toLocaleString('de-DE', { dateStyle: 'medium' })
// "16. März 2026"
// ISO string
date.toString() // "2026-03-16"
// For custom formats like "03/16/26", use template literals
const d = Temporal.PlainDate.from('2026-03-16')
`${String(d.month).padStart(2, '0')}/${String(d.day).padStart(2, '0')}/${String(d.year).slice(-2)}`
// "03/16/26"
If you need a custom format string like "YYYY-MM-DD HH:mm", Temporal doesn't have this built-in. For complex formatting, a small formatting utility or Intl.DateTimeFormat with options covers most cases.
Migrating from Moment.js
Moment.js is in legacy mode (no new features since 2020). The migration to Temporal is the recommended path:
Common Patterns
// 1. Current date
moment() → Temporal.Now.plainDateTimeISO()
// 2. Parse ISO string
moment('2026-03-16') → Temporal.PlainDate.from('2026-03-16')
// 3. Format
moment().format('YYYY-MM-DD') → Temporal.Now.plainDateISO().toString()
moment().format('MM/DD/YYYY') → // Use template literal or Intl
// 4. Add time
moment().add(7, 'days') → Temporal.Now.plainDateISO().add({ days: 7 })
// 5. Difference in days
moment(a).diff(b, 'days') → Temporal.PlainDate.from(a).until(
Temporal.PlainDate.from(b),
{ largestUnit: 'day' }
).days
// 6. Is before/after
moment(a).isBefore(b) → Temporal.PlainDate.compare(
Temporal.PlainDate.from(a),
Temporal.PlainDate.from(b)
) < 0
// 7. Start of month
moment().startOf('month') → Temporal.Now.plainDateISO().with({ day: 1 })
// 8. End of month
moment().endOf('month') → Temporal.Now.plainDateISO()
.add({ months: 1 })
.with({ day: 1 })
.subtract({ days: 1 })
Why Moment.js Became a Legacy Library
Moment.js was revolutionary when it launched in 2011. Before Moment, date handling in JavaScript was a patchwork of custom utilities and browser quirks. Moment provided a consistent, chainable API that worked everywhere and shipped with locale-aware formatting for dozens of languages. For five years it was the uncontested standard.
The problems emerged slowly. Moment's mutable API turned out to be a footgun — const tomorrow = today.add(1, 'day') doesn't create a new date, it mutates today in place and returns the same object. This caused subtle bugs in React applications where mutating date objects bypassed change detection. The library also stopped the clock on tree shaking: Moment's locale files shipped as a single 300KB+ bundle, and the architecture made it nearly impossible to import only the locales you needed.
By 2020, the Moment.js team made it official: the library is in "legacy mode." No new features, maintenance only. The recommendation in their own documentation is to evaluate alternatives. For teams building new applications, Moment is not a reasonable choice in 2026.
What You Lose
Moment has a few features with no direct Temporal equivalent:
// Relative time ("2 hours ago", "in 3 days") — Temporal has no fromNow()
moment('2026-03-10').fromNow() // "6 days ago"
// For this, use Intl.RelativeTimeFormat (native) or a small library
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
rtf.format(-6, 'day') // "6 days ago"
The lack of a built-in relative time function is the most commonly cited friction point when migrating from Moment.js. However, Intl.RelativeTimeFormat is now supported in all modern browsers and Node.js 12+. For most use cases — "5 minutes ago," "in 2 days," "last week" — the native API is sufficient and adds zero bundle weight.
For more complex relative time needs (automatic unit selection based on distance, countdown formatting), the small library timeago.js (1.2KB gzip) or the humanize-duration package work well alongside Temporal. The Temporal Duration type computes the difference between dates with any unit granularity, so feeding that duration to a formatting library is straightforward.
Moment's other missing feature in Temporal is its extensive locale support. moment.locale('fr') automatically formatted dates in French across all format calls. Temporal delegates locale concerns entirely to Intl.DateTimeFormat, which means locale-aware formatting requires passing locale options explicitly to each format call. This is more verbose but also more correct — it forces you to be explicit about which locale you want rather than setting a global mutable locale state.
Migrating from date-fns
date-fns and Temporal have more conceptual overlap since both are functional/immutable. The migration is mostly syntax:
// 1. Parse
parseISO('2026-03-16') → Temporal.PlainDate.from('2026-03-16')
// 2. Add
addDays(date, 7) → temporalDate.add({ days: 7 })
addMonths(date, 1) → temporalDate.add({ months: 1 })
addYears(date, 1) → temporalDate.add({ years: 1 })
// 3. Subtract
subDays(date, 7) → temporalDate.subtract({ days: 7 })
// 4. Difference
differenceInDays(a, b) → Temporal.PlainDate.from(b)
.until(a, { largestUnit: 'day' }).days
// 5. Format
format(date, 'yyyy-MM-dd') → temporalDate.toString()
format(date, 'MMMM d, yyyy') → temporalDate.toLocaleString('en-US', { dateStyle: 'long' })
// 6. Is valid
isValid(date) → try { Temporal.PlainDate.from(str); return true }
catch { return false }
// 7. Start/end of period
startOfMonth(date) → temporalDate.with({ day: 1 })
endOfMonth(date) → temporalDate.with({ day: temporalDate.daysInMonth })
startOfYear(date) → temporalDate.with({ month: 1, day: 1 })
What date-fns Still Does Better
// Complex format strings — still easier in date-fns
format(date, 'EEE, MMM d') // "Mon, Mar 16"
// Temporal: manual construction or Intl options
// Locale-aware formatting without Intl options
formatDistance(date, new Date()) // "3 days ago"
// Temporal: use Intl.RelativeTimeFormat directly
Using Temporal Today: The Polyfill
Two polyfills are available. For most production projects, temporal-polyfill is the better choice:
# Recommended: smaller, production-optimized
npm install temporal-polyfill
# Reference implementation: larger, full TC39 compliance guarantee
npm install @js-temporal/polyfill
// temporal-polyfill (~20KB gzip) — recommended
import { Temporal } from 'temporal-polyfill'
// @js-temporal/polyfill (~44KB gzip) — full reference implementation
import { Temporal, Intl, toTemporalInstant } from '@js-temporal/polyfill'
Date.prototype.toTemporalInstant = toTemporalInstant // legacy Date interop
temporal-polyfill (maintained by FullCalendar) is ~2x smaller and passes the overwhelming majority of the TC39 test suite. @js-temporal/polyfill is the official reference implementation from the proposal champions — use it if you need the absolute last edge case covered. FullCalendar's involvement is meaningful: they're one of the most widely used date display libraries in the JavaScript ecosystem and have been building on top of browser date APIs for over a decade. The temporal-polyfill reflects that deeply accumulated expertise in date edge-case handling.
Once Chrome 144+ is your minimum baseline (likely 2027 for most apps), you can remove the polyfill and the bundle drops to 0.
Temporal and TypeScript Build Tools
The polyfill works correctly with modern TypeScript bundlers. With Vite, import the polyfill in your main entry file and the tree shaker will include only the portions of the polyfill used in your code. With tsup or esbuild, the polyfill is bundled normally. Server-side frameworks (Next.js, Remix, TanStack Start) work with the polyfill in both server and client code.
One common integration mistake: importing from 'temporal-polyfill' in multiple files causes the polyfill to be bundled multiple times. The standard pattern is to import and re-export Temporal from a single utilities module:
// utils/dates.ts — import once, re-export everywhere
export { Temporal } from 'temporal-polyfill'
// Elsewhere in the app
import { Temporal } from '../utils/dates'
This pattern also makes the eventual migration away from the polyfill (when native Temporal lands in Node.js and all target browsers) a single-file change. The temporal-polyfill package is designed explicitly to be a drop-in replacement for native Temporal — when you remove the import, the rest of your code runs unchanged against the engine's native implementation. This forward-compatibility guarantee is one of the most compelling reasons to adopt Temporal now rather than waiting for native support to reach baseline.
// Future-proof code: write polyfill-compatible Temporal now
// When native support is baseline, just remove the polyfill import
When to Use What in 2026
Use Temporal (with polyfill) if:
- You're starting a new project and want to write forward-compatible code
- You have complex time zone requirements that need ZonedDateTime
- You need calendar arithmetic beyond Gregorian (ISO, Japanese, Hebrew)
- You want Duration as a first-class type for scheduling or countdown logic
Keep date-fns v4 if:
- You have an existing date-fns codebase — migration effort isn't worth it yet
- You need complex format strings (
format(date, 'EEE, MMM d, h:mm a')) - You're on Node.js services and native Temporal isn't production-ready yet (no unflagged support as of March 2026)
- Your team knows date-fns well and Temporal's type system adds learning overhead
Finally remove Moment.js:
- Moment is 67KB uncompressed, mutable, and in legacy mode
- If you're on Moment, migrate to either date-fns v4 or Temporal now
- There is no reason to use Moment in a new project in 2026
The Time Zone Case Study
The timing question for adopting Temporal comes down to your deployment environment. For browser-only applications targeting modern browsers, Temporal with the 20KB polyfill is a reasonable choice today. The polyfill adds minimal bundle overhead, and your code will automatically benefit from native performance when Chrome/Firefox/Safari implementations replace the polyfill in each browser's update cycle.
For Node.js applications and server-side rendering, the picture is less clear. Node.js v24+ supports Temporal but behind a flag; the unflagged release is expected in Node.js v26 LTS (late 2025/early 2026). Until Node.js LTS support is unflagged, server-side code should use the polyfill. The good news: the polyfill API is identical to native Temporal, so no code changes are required when you remove it.
Date-fns v4 remains a legitimate choice for any team not ready to adopt an API proposal. It is tree-shakable, TypeScript-first, and has over 5 years of production hardening behind its current API surface. If your date handling is primarily parsing, formatting, and simple arithmetic without complex time zone requirements, date-fns v4 is stable and sufficient.
The meaningful difference emerges in applications with genuine international time zone complexity: calendaring software, global meeting schedulers, event platforms that display times in users' local time zones, or financial applications with market hours across time zones. For these use cases, Temporal.ZonedDateTime is meaningfully better than any Date-based library, because it was designed specifically to solve these problems correctly.
Temporal's biggest win over everything else is time zones. Here's a common real-world scenario — a "next meeting" scheduler:
// Schedule a recurring meeting for every Monday at 9am Eastern
// that stays at 9am ET even across DST transitions
// Moment-timezone (broken for DST edge cases)
const next = moment.tz('America/New_York').day(1).hour(9).minute(0)
// DST bugs: this might give 8am or 10am after a DST transition
// Temporal (correct)
function nextMondayAt9amET() {
const now = Temporal.Now.zonedDateTimeISO('America/New_York')
// Find next Monday
const daysUntilMonday = (8 - now.dayOfWeek) % 7 || 7
const nextMonday = now.add({ days: daysUntilMonday })
// Set to 9am — Temporal handles DST correctly
return nextMonday.with({ hour: 9, minute: 0, second: 0 })
}
const meeting = nextMondayAt9amET()
console.log(meeting.toString())
// "2026-03-23T09:00:00-04:00[America/New_York]"
// Always 9am ET — even if DST changes between now and then
Ecosystem & Community
The Temporal proposal has been developed in public on GitHub at tc39/proposal-temporal for over five years. The proposal champions — Philipp Dunkel, Ujjwal Sharma, and Justin Grant — have engaged extensively with developer feedback throughout the process. The test suite is the most comprehensive of any TC39 proposal, with thousands of edge-case tests covering leap years, DST transitions, calendar system arithmetic, and cross-implementation behavior.
The polyfill ecosystem is in good shape. temporal-polyfill by FullCalendar builds on their deep date library expertise — FullCalendar has been solving date-related edge cases for over a decade. @js-temporal/polyfill is maintained by the proposal champions themselves and is the authoritative reference for edge cases.
Browser vendor adoption has been notably fast since Stage 4. Chrome 144 shipped in early 2026, Firefox 139 followed, and the WebKit team has shown positive signals for near-term support. The Node.js tracking issue shows active work to remove the experimental flag in v26.
Real-World Adoption
The earliest Temporal adopters have been teams with genuinely hard date problems — international e-commerce platforms handling multi-currency, multi-timezone order processing; healthcare scheduling systems that need to correctly handle patient appointments across DST boundaries; financial platforms computing settlement dates in multiple calendar systems.
For these use cases, the value of Temporal.ZonedDateTime over Moment.js or date-fns is not abstract — it's the difference between a subtle bug that surfaces twice a year during daylight saving time and a system that handles the transition correctly by design.
More broadly, any team starting a TypeScript project in 2026 and needing date handling should default to the polyfill. The ergonomics are noticeably better than date-fns — the object-oriented API with method chaining is more discoverable than date-fns's functional style, and the type system makes it harder to confuse PlainDate with ZonedDateTime in ways that date-fns's universal Date type cannot prevent.
Developer Experience Deep Dive
TypeScript support for Temporal is first-class — the types ship with temporal-polyfill and @js-temporal/polyfill, and TypeScript 5.x understands the Temporal type hierarchy out of the box. The discriminated union between PlainDate, PlainDateTime, ZonedDateTime, and Instant makes it impossible to accidentally pass a timezone-naive date where a timezone-aware one is required.
The IntelliSense experience in VS Code is excellent. Every Temporal type's methods are well-documented in the type definitions, and the suggestion list for each type is focused — PlainDate doesn't show timeZone methods because it has none. This prevents the class of errors where developers assume a date variable includes timezone information when it doesn't.
The main friction point in the developer experience is the absence of a format string API. Developers migrating from moment().format('MMMM D, YYYY') or format(date, 'MMMM d, yyyy') need to switch to toLocaleString options or template literals. The Intl.DateTimeFormat API is powerful but its option names are verbose and less memorable than format strings.
Temporal in TypeScript Projects
Temporal is a natural fit for TypeScript projects. The type system maps cleanly to Temporal's conceptual model — PlainDate, PlainDateTime, ZonedDateTime, and Instant are distinct types that TypeScript tracks separately, preventing the category of errors where a timezone-naive date is used where a timezone-aware one is expected.
Practical TypeScript patterns with Temporal:
// Utility type that accepts any Temporal date type for display
type TemporalDate = Temporal.PlainDate | Temporal.ZonedDateTime
function formatForDisplay(date: TemporalDate, locale: string = 'en-US'): string {
return date.toLocaleString(locale, { dateStyle: 'medium' })
}
// Type guard to distinguish types
function isZonedDateTime(date: TemporalDate): date is Temporal.ZonedDateTime {
return date instanceof Temporal.ZonedDateTime
}
// Database round-trip pattern: store as ISO string, reconstruct as Temporal
interface EventRecord {
id: string
startTime: string // ISO 8601 with timezone offset stored in DB
timeZone: string // IANA timezone name
}
function recordToEvent(record: EventRecord): Temporal.ZonedDateTime {
return Temporal.ZonedDateTime.from(`${record.startTime}[${record.timeZone}]`)
}
The pattern of storing dates as ISO 8601 strings with timezone information in the database, then reconstructing ZonedDateTime objects in application code, is clean and robust. It avoids the common mistake of storing dates as UTC timestamps and then losing timezone context.
Temporal and React State
One practical benefit of Temporal's immutable design in React applications: you can store PlainDate, PlainDateTime, or ZonedDateTime instances directly in React state without defensive cloning. The useState and useReducer hooks rely on reference equality to detect changes — Temporal's immutable operations always return new object instances, so React's change detection works correctly without any extra effort:
const [selectedDate, setSelectedDate] = useState(Temporal.Now.plainDateISO())
// These are safe — Temporal returns new objects, never mutates
function handleNextDay() {
setSelectedDate(prev => prev.add({ days: 1 }))
}
function handlePrevDay() {
setSelectedDate(prev => prev.subtract({ days: 1 }))
}
Compare this to Moment.js, where state.date.add(1, 'day') silently mutates the stored Moment object and React won't detect the change unless you clone it first. Temporal eliminates this class of React state bugs by design.
Temporal with Zod Validation
Input validation for date fields is a common pain point. When combined with Zod, Temporal provides strong guarantees about parsed date values:
import { z } from 'zod'
import { Temporal } from 'temporal-polyfill'
const temporalDate = z.string().transform((val, ctx) => {
try {
return Temporal.PlainDate.from(val)
} catch {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid date' })
return z.NEVER
}
})
// Usage: API input validation
const bookingSchema = z.object({
checkIn: temporalDate,
checkOut: temporalDate,
}).refine(data => Temporal.PlainDate.compare(data.checkIn, data.checkOut) < 0, {
message: 'Check-out must be after check-in',
})
This pattern — Zod schema that produces Temporal values — means your application code works exclusively with valid, strongly-typed date objects rather than raw strings. Invalid dates are caught at the API boundary before they reach business logic.
Practical Comparison: Subscription Billing
Date arithmetic is where Temporal's advantages over date-fns and Moment.js are most tangible in business applications. Consider a subscription billing system that needs to calculate renewal dates:
// Scenario: monthly subscription that renews on the same day of month
// Edge case: subscribe on Jan 31, when does Feb renewal occur?
// Moment.js (ambiguous behavior)
const subscribeDate = moment('2026-01-31')
const renewalDate = subscribeDate.add(1, 'month')
// renewalDate.date() is 28 (Feb) — silently clips to last day of month
// Temporal (explicit behavior you control)
const subscribeDate = Temporal.PlainDate.from('2026-01-31')
const renewalDate = subscribeDate.add({ months: 1 })
// renewalDate is 2026-02-28 — same implicit behavior, but you can be explicit:
// Option A: Clip to last day of month (usually what billing systems want)
const nextMonth = subscribeDate.add({ months: 1 })
// → 2026-02-28
// Option B: Overflow — next day after end of month
const nextMonth = subscribeDate.add({ months: 1 }, { overflow: 'reject' })
// → throws RangeError — lets you handle edge case explicitly
// Option C: Calculate explicitly
function nextBillingDate(current: Temporal.PlainDate): Temporal.PlainDate {
const next = current.add({ months: 1 })
// If day changed due to month-end overflow, use end of target month
if (next.day !== current.day) {
return next.with({ day: next.daysInMonth })
}
return next
}
Temporal forces you to think about these edge cases explicitly rather than inheriting Moment.js's implicit behavior — which may or may not match what your billing system actually requires.
Browser and Node.js Support
Temporal native support (March 2026):
Chrome 144+ ✅ stable
Firefox 139+ ✅ stable
Safari ⚠️ partial (Technology Preview)
Edge 144+ ✅ stable
Node.js v24+ ⚠️ behind flag (--harmony-temporal) — NOT unflagged as of March 2026
Bun ❌ not yet
Deno ❌ not yet
Node.js Temporal tracking: github.com/nodejs/node#57891 (open, awaiting triage as of March 2026)
Production Node.js: use a polyfill, not the native flag.
Polyfill options:
temporal-polyfill: ✅ ~20KB gzip (recommended)
@js-temporal/polyfill: ✅ ~44KB gzip (reference implementation)
Both pass the TC39 test suite; temporal-polyfill is 2x smaller
Track Moment.js vs date-fns vs Temporal polyfill npm trends on PkgPulse.
Related:
date-fns v4 vs Temporal vs Day.js
Best JavaScript date libraries in 2026
Best JavaScript testing frameworks in 2026
Compare Temporal-api and Momentjs package health on PkgPulse.
Migration Strategy
Replacing date handling in an existing codebase is a higher-risk change than it looks. Date logic accumulates edge cases — timezone conversions, DST transitions, calendar arithmetic — that are rarely covered by tests because they work correctly with the current library.
Migrating from Moment.js: The biggest risk is the mutable API. Moment's chaining methods mutate in place, so calls like moment().add(1, 'day') return the same object modified. Both date-fns and Temporal use immutable data structures, which means you need to audit every place where you stored a Moment object and expected it to stay unchanged. Run your test suite with Istanbul coverage to find which paths are uncovered.
Migrating from date-fns: date-fns to Temporal is a future migration that should wait for Temporal to reach Stage 4 and land in all major runtimes without a polyfill. The API shape is different enough that migration is non-trivial. If you are on date-fns today, you are in a good position — stay until Temporal is broadly available.
Migrating from Luxon or Day.js: Both have relatively clean immutable APIs that map reasonably to Temporal's concepts. The larger surface area to audit is timezone handling — Temporal's ZonedDateTime is more explicit than Luxon's DateTime.setZone().
Regardless of which library you're leaving, write the migration as a series of small PRs rather than a single large change. Replace one usage pattern at a time — parse, format, arithmetic, comparison — and verify each in production before continuing.