JavaScript Date Is Broken. Here's What Replaces It

Have you ever scheduled a meeting for 3 PM, only to have a colleague across the Atlantic show up at the wrong time? Or built an event system that worked perfectly in development, then watched it produce mystifying bugs the moment users in different timezones touched it?
If so, you've run into one of JavaScript's longest-running embarrassments: the Date object.
The good news: after years of committee work, JavaScript is finally getting a proper date and time API. The TC39 Temporal proposal is the biggest improvement to JavaScript's handling of dates since the language was created. In this tutorial, I'll walk through building a timezone-aware event scheduler with React and TypeScript that shows why Temporal matters and how to use it today.
TL;DR
We're building a scheduler where users can create events in any timezone, view them in their local timezone, and sort them correctly regardless of origin. The insight that makes this work: Temporal separates the instant in time from its human representation, which eliminates entire categories of bugs that plague Date-based applications.
You can follow along with the code on GitHub or steal these patterns for your own projects. The Temporal API is available now via polyfill.
Prerequisites
Before we start, you'll need:
- Familiarity with React and TypeScript
- Node.js 18 or later
- A basic understanding of what timezones are (though not necessarily how they work internally)
No prior Temporal experience required.
What's wrong with Date, exactly?
The Date object was designed in ten days in 1995. It shows.
Here's why it's unsuitable for serious datetime work:
Mutability. Date objects can be modified in place. Call setMonth() and you've changed the original object. This makes Date objects dangerous to pass around.
Timezone confusion. A Date represents an instant in time, but its methods (getHours(), toString()) silently use the local timezone. There's no way to create a Date that "knows" it belongs to a specific timezone. You can't represent "3 PM in Tokyo" directly.
No duration arithmetic. Adding 30 days requires manual millisecond math. Adding "one month" is a question Date doesn't even attempt to answer.
Parsing nightmares. Date.parse() is implementation-dependent. The string "2026-01-20" parses as midnight UTC in some browsers and midnight local time in others. This is the bug that passes all your tests and breaks in production.
No separation of concepts. A calendar date, a wall-clock time, and a specific moment in history are different things. Date conflates them all.
Consider this:
const meeting = new Date('2026-03-15T14:00'); console.log(meeting.toISOString());
What time is this meeting? Depends on which timezone your JavaScript runtime is configured for. The same code produces different results on different machines. This isn't a bug. This is how Date was designed.
Enter Temporal: the mental model
Temporal introduces distinct types for distinct concepts:
Temporal.Instant- an exact moment in time (like a Unix timestamp, but with nanosecond precision)Temporal.PlainDate- a calendar date with no time or timezoneTemporal.PlainTime- a wall-clock time with no date or timezoneTemporal.PlainDateTime- a date and time with no timezoneTemporal.ZonedDateTime- a date, time, and timezone together
This separation matters. When a user selects "March 15, 2026 at 2 PM" in a form, they're specifying a PlainDateTime. When they also select "America/New_York" as the timezone, that combination becomes a ZonedDateTime. And when you need to compare that event to one in Tokyo, both convert to Instant values on the same global timeline.
Temporal objects are immutable. Operations return new objects rather than modifying existing ones. One less thing to worry about.
Project setup
Create a new React project with Vite and install the Temporal polyfill:
npm create vite@latest timezone-scheduler -- --template react-ts cd timezone-scheduler npm install @js-temporal/polyfill npm install -D tailwindcss @tailwindcss/vite
The @js-temporal/polyfill gives you a complete Temporal implementation today. When browsers ship native support, you can drop the polyfill and your code keeps working.
Configure your vite.config.ts to include Tailwind:
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], });
Building the Temporal utils layer
Rather than scattering Temporal calls throughout the application, I put common operations in a utility layer. Easier to test, easier to change later.
The simplest function shows how Temporal handles timezone detection:
import { Temporal } from '@js-temporal/polyfill'; export function getLocalTimezone(): string { return Temporal.Now.timeZoneId(); }
This returns an IANA timezone identifier like "America/New_York" or "Asia/Tokyo". Unlike the old approach of parsing Date.toString() or using Intl.DateTimeFormat, it just works.
Next, we need to create ZonedDateTime objects from user input. When someone fills out an event form, they provide a local datetime string (from an <input type="datetime-local">) and a timezone selection:
export function createZonedDateTime( dateTimeLocal: string, timezone: string ): Temporal.ZonedDateTime { const plainDateTime = Temporal.PlainDateTime.from(dateTimeLocal); return plainDateTime.toZonedDateTime(timezone); }
Two steps: parse the "plain" datetime without timezone context, then attach the timezone they selected. The from() method accepts ISO 8601 strings like "2026-03-15T14:00".
Converting between timezones is where Temporal earns its keep:
export function convertTimezone( zonedDateTime: Temporal.ZonedDateTime, targetTimezone: string ): Temporal.ZonedDateTime { return zonedDateTime.withTimeZone(targetTimezone); }
One method call. The withTimeZone method returns a new ZonedDateTime representing the same instant but displayed in a different timezone. The underlying moment doesn't change; only its human representation does. This is exactly what you want when showing a New York event to someone in London.
For relative times like "in 2 hours" or "3 days ago," Temporal provides duration arithmetic that actually works:
export function getRelativeTime(zonedDateTime: Temporal.ZonedDateTime): string { const currentTime = now(); const isPastTime = Temporal.ZonedDateTime.compare(zonedDateTime, currentTime) < 0; const duration = zonedDateTime.since(currentTime, { largestUnit: 'days', }); const totalMinutes = Math.abs( duration.days * 24 * 60 + duration.hours * 60 + duration.minutes ); const suffix = isPastTime ? 'ago' : 'from now'; if (Math.abs(duration.days) >= 1) { const days = Math.abs(duration.days); return `${days} day${days !== 1 ? 's' : ''} ${suffix}`; } if (totalMinutes >= 60) { const hours = Math.floor(totalMinutes / 60); return `${hours} hour${hours !== 1 ? 's' : ''} ${suffix}`; } if (totalMinutes >= 1) { return `${totalMinutes} minute${totalMinutes !== 1 ? 's' : ''} ${suffix}`; } return 'now'; }
The since() method returns a Temporal.Duration with properties for days, hours, minutes, and so on. The largestUnit option controls how the duration is balanced. With Date, implementing this correctly means careful handling of daylight saving time transitions. Temporal handles those automatically.
One more utility: displaying timezone offsets. Some timezones have non-integer hour offsets (India is UTC+5:30, Nepal is UTC+5:45), and Temporal gives you this directly:
export function getTimezoneOffset(timezone: string): string { const zdt = now(timezone); const offsetNanoseconds = zdt.offsetNanoseconds; const totalMinutes = offsetNanoseconds / (1000 * 1000 * 1000 * 60); const hours = Math.trunc(totalMinutes / 60); const minutes = Math.abs(totalMinutes % 60); const sign = totalMinutes >= 0 ? '+' : ''; if (minutes === 0) { return `UTC${sign}${hours}`; } return `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}`; }
Notice the use of offsetNanoseconds rather than hours. Temporal provides nanosecond precision throughout, and offsets are expressed in the smallest unit to avoid floating-point issues.
State management with Context
The scheduler needs to track events and the user's current view timezone. React Context with useReducer works well here.
Type definitions first:
export interface ScheduledEvent { id: string; title: string; description?: string; startTime: string; // ISO 8601 format with timezone offset timezone: string; // IANA timezone identifier createdAt: string; } export interface EventsState { events: ScheduledEvent[]; viewTimezone: string; }
Events store their datetime as ISO strings with offsets, plus the original timezone identifier. This preserves everything needed to display the event correctly in any timezone.
The context provider handles localStorage persistence with a pattern that prevents hydration bugs:
export function EventsProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(eventsReducer, initialState); const [isLoaded, setIsLoaded] = useState(false); // Load events from localStorage on mount useEffect(() => { try { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored) as unknown[]; const events = parsed.filter(validateEvent); dispatch({ type: 'LOAD_EVENTS', payload: events }); } } catch (error) { console.error('Failed to load events from localStorage:', error); } setIsLoaded(true); }, []); // Save events to localStorage whenever they change (after initial load) useEffect(() => { if (!isLoaded) return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.events)); } catch (error) { console.error('Failed to save events to localStorage:', error); } }, [state.events, isLoaded]); // ... }
The isLoaded flag prevents the second effect from running until hydration completes. Without it, the initial empty state would overwrite stored events. I've been bitten by this before.
The validation function keeps corrupted data from crashing the app:
function validateEvent(event: unknown): event is ScheduledEvent { return ( typeof event === 'object' && event !== null && typeof (event as ScheduledEvent).id === 'string' && typeof (event as ScheduledEvent).title === 'string' && typeof (event as ScheduledEvent).startTime === 'string' && typeof (event as ScheduledEvent).timezone === 'string' ); }
This type guard filters out malformed entries during hydration. TypeScript's type predicates make it both safe and ergonomic.
The event card: dual timezone display
The event card is where the Temporal utilities come together. Each card can display its time in either the original timezone or the user's view timezone:
const originalZdt = parseISOToZonedDateTime(event.startTime, event.timezone); const viewZdt = convertTimezone(originalZdt, state.viewTimezone); const displayZdt = showInOriginalTz ? originalZdt : viewZdt; const eventIsPast = isPast(originalZdt);
The component maintains both representations and switches between them based on user preference. The isPast check uses the original timezone because that's the actual moment the event occurs, regardless of how it's displayed.
This solves a common problem: showing a meeting scheduled at "3 PM Tokyo" to someone in New York. They see "1 AM New York" with a toggle to view the original. No manual offset calculations, no daylight saving time bugs.
Sorting events across timezones
A list of events from different timezones needs to sort by actual occurrence, not by the numeric values of their local times. An event at 9 AM in Tokyo happens before an event at 9 AM in New York.
const sortedEvents = useMemo(() => { return [...state.events].sort((a, b) => { const aZdt = parseISOToZonedDateTime(a.startTime, a.timezone); const bZdt = parseISOToZonedDateTime(b.startTime, b.timezone); return Temporal.ZonedDateTime.compare(aZdt, bZdt); }); }, [state.events]);
Temporal.ZonedDateTime.compare() compares by instant, not wall-clock time. Exactly right for sorting a mixed-timezone list.
With Date, you'd convert both to UTC milliseconds and compare those. Possible, but error-prone. With Temporal, the comparison is explicit and correct by default.
What I learned
A few patterns worth noting:
Store timezone identifiers, not offsets. IANA identifiers like "America/New_York" encode daylight saving rules. UTC offsets like -05:00 don't. Store an offset and you lose the ability to correctly handle events that span a DST transition.
Parse to Instant for storage, ZonedDateTime for display. ISO strings with offsets (2026-03-15T14:00:00-05:00) can be parsed to either. Use Instant.from() when you need the universal timeline, ZonedDateTime.from() when you need to preserve original timezone context.
The polyfill is larger than you might expect. The @js-temporal/polyfill adds roughly 50KB minified. For many applications that's fine, but worth knowing.
Timezone data isn't static. The IANA Time Zone Database gets updates several times per year as governments change their DST rules. The polyfill bundles a snapshot, but production applications may need an update strategy.
Temporal.Duration can surprise you. A duration of "1 month" is ambiguous. January to February is 31 days; February to March is 28 or 29. Temporal handles this, but you should understand when you're working with "calendar" durations versus fixed durations.
Further reading
- JavaScript Dates & Temporal - My earlier deep dive on the Temporal proposal
- TC39 Temporal Proposal - The official specification
- @js-temporal/polyfill - Production-ready polyfill
- IANA Time Zone Database - The authoritative source for timezone data
Closing thoughts
The Temporal API isn't a small change. It introduces a new mental model for time in JavaScript, one that distinguishes between moments, calendar dates, wall-clock times, and the timezones that connect them.
For applications that deal seriously with time, this precision isn't academic. Scheduling systems, booking platforms, financial applications—they've all spent years working around Date's limitations. Temporal doesn't make these problems disappear, but it gives you tools that match the actual complexity.
The proposal reached Stage 3 in TC39, meaning the API is stable and browser implementation is underway. Use the polyfill today and your code will work unchanged when native support lands.
Time is hard. At least now our tools admit it.