Solving the Notorious Date/Time Shifting Bug: A Complete Guide to Timezone Handling in React
Have you ever experienced this nightmare scenario? You input 30 AUG 2:30 PM into your application, but when you check later, the system shows 1st September! Or perhaps your international users complain that event times are completely wrong on their end. This isn't a rare bug—it's one of the most common and frustrating issues in web development. Let's dive deep into why this happens and how to solve it permanently.
The Problem That Haunts Every Developer
Have you ever experienced this nightmare scenario? You input "30 AUG 2:30 PM" into your application, but when you check later, the system shows "1st September"! Or perhaps your international users complain that event times are completely wrong on their end.
This isn't a rare bug—it's one of the most common and frustrating issues in web development. Let's dive deep into why this happens and how to solve it permanently.
The Problematic Code Pattern
Here's a typical React form component that seems innocent but contains severe timezone bugs:
// ❌ PROBLEMATIC CODE - Don't use this pattern!
const [startDate, setStartDate] = useState<Date | undefined>(
event?.startDate ? new Date(event.startDate) : undefined // Timezone ambiguity
);
const [startTime, setStartTime] = useState(
event?.startDate
? new Date(event.startDate).toTimeString().slice(0, 5) // Local conversion
: "10:00"
);
// This function is a timezone disaster waiting to happen
const combineDateAndTime = (date: Date, time: string): string => {
const [hours, minutes] = time.split(":").map((num) => parseInt(num));
const combined = new Date(date); // Preserves original timezone
combined.setHours(hours, minutes, 0, 0); // But sets local hours
// Manual formatting without timezone consideration
const year = combined.getFullYear();
const month = String(combined.getMonth() + 1).padStart(2, "0");
const day = String(combined.getDate()).padStart(2, "0");
const hour = String(combined.getHours()).padStart(2, "0");
const minute = String(combined.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:${minute}`; // Missing timezone info!
};
// Form submission with ambiguous dates
const handleSubmit = async (data) => {
await onSubmit(data); //Sending local times without timezone context
};
Why This Code Fails Spectacularly
- Mixed Date Representations: The code mixes local Date objects with ISO strings
- Timezone Ambiguity: No clear indication of which timezone the dates represent
- Manual Date Formatting: Creating date strings without proper timezone handling
- Dual State Management: Managing the same data in multiple places leads to sync issues
Common Problem Scenarios
International Users
Different time zones across countries or regions can cause the same event to appear at different times for different users. Example: An event scheduled at 7 PM Sydney time might show up as 4 AM in New York.
The Fundamental Issue
When you create a Date object from a string without time zone information:
// What timezone is this?
const date = new Date("2024-08-30T14:30"); // Assumes local timezone!
// When sent to server, it might be converted to UTC
// When retrieved later, UTC gets interpreted as local again
// Result: Date shifts depending on timezone differences!
The Shifting Timeline
Here's exactly what happens to cause date shifting:
- User Input: "30 AUG 2:30 PM" (Local time, no timezone specified)
- JavaScript Processing:
new Date("2024-08-30T14:30")
→ Assumes local timezone - Server/Database Storage: Converts to UTC (maybe "2024-08-30T06:30Z" if user is UTC+8)
- Data Retrieval: UTC string gets parsed as local time again
- Display: "2024-08-30T06:30" interpreted as local → Shows "30 AUG 6:30 AM" ❌
The Solution: Proper UTC-First Architecture
The Golden Rule
Always store UTC in the database, always display in user's local time zone
Core Utilities for Proper Date Handling
import { zonedTimeToUtc, utcToZonedTime, format, parseISO } from 'date-fns-tz';
// Get user's current timezone
export const getUserTimezone = (): string => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
// Convert local datetime input to UTC for storage
export const localToUTC = (localDateTimeString: string): string => {
const userTimezone = getUserTimezone();
const localDate = new Date(localDateTimeString);
const utcDate = zonedTimeToUtc(localDate, userTimezone);
return utcDate.toISOString(); // Always return UTC ISO string
};
// Convert UTC from database to user's local timezone
export const utcToLocal = (utcString: string): Date => {
const timezone = getUserTimezone();
const utcDate = parseISO(utcString);
return utcToZonedTime(utcDate, timezone);
};
// Format for datetime-local input (editing existing events)
export const formatForDateTimeInput = (utcString: string): string => {
const localDate = utcToLocal(utcString);
return format(localDate, "yyyy-MM-dd'T'HH:mm");
};
The Corrected Component
"use client";
import { useForm, SubmitHandler } from "react-hook-form";
import { localToUTC, formatForDateTimeInput } from "@/utils/dateTime";
export default function EventForm({ event, onSubmit, isLoading = false }) {
// 🌱Helper to safely format datetime for input
const formatForInput = (dateStr?: string): string => {
if (!dateStr) return "";
try {
return formatForDateTimeInput(dateStr); // Converts UTC to local for editing
} catch (error) {
console.error("Date formatting error:", error);
return "";
}
};
const form = useForm<EventFormData>({
resolver: zodResolver(eventFormSchema),
defaultValues: {
title: event?.title || "",
// ... other fields
startDate: formatForInput(event?.startDate), // 🌱UTC to local conversion
endDate: formatForInput(event?.endDate),
// ... other fields
},
});
// 🌱Proper submission handling
const handleSubmit: SubmitHandler<EventFormData> = async (data) => {
const processedData = {
...data,
// 🌱Convert local datetime inputs to UTC before submitting
startDate: data.startDate ? localToUTC(data.startDate) : "",
endDate: data.endDate ? localToUTC(data.endDate) : "",
};
await onSubmit(processedData);
};
return (
<div className="space-y-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
{/* 🌱Simple, native datetime inputs */}
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date & Time *</FormLabel>
<FormControl>
<Input
type="datetime-local"
{...field}
className="w-full max-w-xs"
/>
</FormControl>
<FormDescription>
Times are in your local timezone
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>End Date & Time *</FormLabel>
<FormControl>
<Input
type="datetime-local"
{...field}
className="w-full max-w-xs"
/>
</FormControl>
<FormDescription>
Times are in your local timezone
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* ... other form fields */}
<div className="flex justify-end gap-3 pt-6">
<Button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : "Save Event"}
</Button>
</div>
</form>
</Form>
</div>
);
}
Input Flow
User Input (Local) → Convert to UTC → Store in Database (UTC only)
Output Flow
Database (UTC) → Convert to User's Local → Display (Local)
Complete Example
// 1. USER INPUT (Australian user creates event)
const userInput = {
title: "Beach Party",
startDateTime: "2024-08-21T19:00", // 7 PM Australian local time
};
// 2. CONVERT TO UTC FOR STORAGE
const utcPayload = {
title: userInput.title,
startDateTimeUTC: localToUTC(userInput.startDateTime), // "2024-08-21T09:00:00.000Z"
};
// 3. STORE IN DATABASE (UTC only)
await database.events.create(utcPayload);
// 4. DIFFERENT USERS VIEW THE SAME EVENT
const eventFromDB = {
title: "Beach Party",
startDateTimeUTC: "2024-08-21T09:00:00.000Z" // UTC from database
};
// 5. DISPLAY FOR DIFFERENT USERS
const australianView = formatForUser(eventFromDB.startDateTimeUTC, 'PPP p', 'Australia/Sydney');
// → "August 21st, 2024 at 7:00 PM AEST"
const chineseView = formatForUser(eventFromDB.startDateTimeUTC, 'PPP p', 'Asia/Shanghai');
// → "August 21st, 2024 at 5:00 PM CST"
const usView = formatForUser(eventFromDB.startDateTimeUTC, 'PPP p', 'America/New_York');
// → "August 21st, 2024 at 5:00 AM EDT"
Why This Architecture is Perfect
Single Source of Truth
- Database only stores UTC timestamps
- No timezone confusion or ambiguity
- All timestamps are directly comparable
User-Centric Display
- Each user sees times in their own timezone automatically
- No manual timezone conversion needed in components
- Consistent experience across all devices
Global Compatibility
- Works seamlessly for users anywhere in the world
- No hardcoded time zone assumptions
- Scales effortlessly to international audiences
Developer-Friendly
// Simple database schema - all UTC
CREATE TABLE events (
id UUID PRIMARY KEY,
title VARCHAR(255),
start_date_time_utc TIMESTAMP, -- Always UTC
created_at_utc TIMESTAMP -- Always UTC
);
// Clean API contracts
POST /api/events { startDateTimeUTC: "2024-08-21T12:00:00.000Z" }
GET /api/events → { startDateTimeUTC: "2024-08-21T12:00:00.000Z" }
The Golden Rules to Follow
Rule #1: Input Processing
// Always convert user input to UTC before sending to API
const handleSubmit = (formData) => {
const payload = {
...formData,
startDate: localToUTC(formData.startDate), // Local → UTC
endDate: localToUTC(formData.endDate),
};
await api.createEvent(payload);
};
Rule #2: Database Storage
-- Database only stores UTC timestamps
INSERT INTO events (start_date_time_utc) VALUES ('2024-08-21T12:00:00.000Z');
Rule #3: Display Logic
// Always convert UTC to user's local timezone for display
const EventCard = ({ event }) => (
<div>
<p>Starts: {formatForUser(event.startDateTimeUTC)}</p> {/* UTC → Local */}
</div>
);
Rule #4: API Consistency
// APIs always send/receive UTC strings
export const eventAPI = {
create: (data: { startDateTimeUTC: string }) => post('/api/events', data),
list: (): Promise<{ startDateTimeUTC: string }[]> => get('/api/events'),
};
Rule #5: State Management
// Keep UTC in global state, convert in components
const useEventStore = create((set) => ({
events: [] as { startDateTimeUTC: string }[], // Always UTC
addEvent: (event) => set(state => ({
events: [...state.events, event] // Store UTC as-is
})),
}));
Dependencies Required
npm install date-fns date-fns-tz
Testing Your Implementation
// Test with different timezones
const testTimezones = [
'Australia/Sydney', // UTC+10/+11
'Asia/Shanghai', // UTC+8
'Europe/London', // UTC+0/+1
'America/New_York', // UTC-5/-4
'America/Los_Angeles' // UTC-8/-7
];
testTimezones.forEach(tz => {
const formatted = formatForUser('2024-08-21T12:00:00.000Z', 'PPP p', tz);
console.log(`${tz}: ${formatted}`);
});
Conclusion
The date/time shifting bug isn't just a minor inconvenience—it's a fundamental architectural problem that affects user experience globally. By implementing UTC-first architecture with proper timezone conversion utilities, you eliminate these issues permanently.
Remember the core principle: Store UTC, Display Local. Follow the golden rules consistently across your application, and you'll never have to debug mysterious date shifting bugs again.
Your users around the world will thank you for getting their event times right!