Date Handling Pattern

Work with dates, times, and timezones using date-fns for consistent, type-safe date manipulation.

Overview#

Date handling is one of the most error-prone areas in web development. This pattern provides utilities for formatting, parsing, timezone conversion, and date comparisons using the battle-tested date-fns library.

When to use:

  • Displaying dates in user interfaces
  • Date range selection and filtering
  • Timezone conversions
  • Relative time display ("2 hours ago")
  • Calendar components
  • Date validation

Key features:

  • Consistent date formatting
  • Timezone-aware operations
  • Relative time formatting
  • Date range utilities
  • React components
  • Zod validation

Code Example#

Core Utilities#

1// lib/dates.ts 2import { 3 format, 4 formatDistanceToNow, 5 parseISO, 6 isValid, 7 startOfDay, 8 endOfDay, 9 addDays, 10 subDays, 11 differenceInDays, 12 isBefore, 13 isAfter, 14 isSameDay 15} from 'date-fns' 16import { formatInTimeZone, toZonedTime } from 'date-fns-tz' 17 18// Format date for display 19export function formatDate( 20 date: Date | string, 21 pattern = 'MMM d, yyyy' 22): string { 23 const d = typeof date === 'string' ? parseISO(date) : date 24 return isValid(d) ? format(d, pattern) : 'Invalid date' 25} 26 27// Format datetime 28export function formatDateTime(date: Date | string): string { 29 return formatDate(date, 'MMM d, yyyy h:mm a') 30} 31 32// Relative time (e.g., "2 hours ago") 33export function timeAgo(date: Date | string): string { 34 const d = typeof date === 'string' ? parseISO(date) : date 35 return formatDistanceToNow(d, { addSuffix: true }) 36} 37 38// Format with timezone 39export function formatWithTimezone( 40 date: Date | string, 41 timezone: string, 42 pattern = 'MMM d, yyyy h:mm a zzz' 43): string { 44 const d = typeof date === 'string' ? parseISO(date) : date 45 return formatInTimeZone(d, timezone, pattern) 46} 47 48// Parse user input 49export function parseDate(input: string): Date | null { 50 const parsed = parseISO(input) 51 return isValid(parsed) ? parsed : null 52}

Date Range Utilities#

1// lib/dates.ts 2export type DateRangePreset = 3 | 'today' 4 | 'yesterday' 5 | 'last7days' 6 | 'last30days' 7 | 'thisMonth' 8 | 'lastMonth' 9 10export function getDateRange(range: DateRangePreset): { 11 start: Date 12 end: Date 13} { 14 const now = new Date() 15 const end = endOfDay(now) 16 17 switch (range) { 18 case 'today': 19 return { start: startOfDay(now), end } 20 case 'yesterday': 21 return { 22 start: startOfDay(subDays(now, 1)), 23 end: endOfDay(subDays(now, 1)) 24 } 25 case 'last7days': 26 return { start: startOfDay(subDays(now, 6)), end } 27 case 'last30days': 28 return { start: startOfDay(subDays(now, 29)), end } 29 case 'thisMonth': 30 return { 31 start: startOfDay(new Date(now.getFullYear(), now.getMonth(), 1)), 32 end 33 } 34 case 'lastMonth': 35 return { 36 start: startOfDay(new Date(now.getFullYear(), now.getMonth() - 1, 1)), 37 end: endOfDay(new Date(now.getFullYear(), now.getMonth(), 0)) 38 } 39 } 40} 41 42// Check if date is within range 43export function isInRange( 44 date: Date, 45 start: Date, 46 end: Date 47): boolean { 48 return !isBefore(date, start) && !isAfter(date, end) 49} 50 51// Get number of days between dates 52export function daysBetween(start: Date, end: Date): number { 53 return differenceInDays(end, start) 54}

Timezone Handling#

1// lib/timezone.ts 2import { toZonedTime, fromZonedTime, formatInTimeZone } from 'date-fns-tz' 3 4// Get user's timezone 5export function getUserTimezone(): string { 6 return Intl.DateTimeFormat().resolvedOptions().timeZone 7} 8 9// Convert UTC to user's timezone 10export function toUserTime(utcDate: Date, timezone?: string): Date { 11 const tz = timezone ?? getUserTimezone() 12 return toZonedTime(utcDate, tz) 13} 14 15// Convert user's local time to UTC 16export function toUTC(localDate: Date, timezone?: string): Date { 17 const tz = timezone ?? getUserTimezone() 18 return fromZonedTime(localDate, tz) 19} 20 21// Format in specific timezone 22export function formatInTz( 23 date: Date, 24 timezone: string, 25 pattern = 'yyyy-MM-dd HH:mm:ss zzz' 26): string { 27 return formatInTimeZone(date, timezone, pattern) 28} 29 30// Get timezone options for select 31export const timezones = Intl.supportedValuesOf('timeZone').map(tz => ({ 32 value: tz, 33 label: tz.replace(/_/g, ' ') 34}))

Date Input Component#

1// components/DateInput.tsx 2'use client' 3 4import { useState } from 'react' 5import { format, parse, isValid } from 'date-fns' 6 7interface DateInputProps { 8 value?: Date 9 onChange: (date: Date | null) => void 10 min?: Date 11 max?: Date 12 disabled?: boolean 13} 14 15export function DateInput({ 16 value, 17 onChange, 18 min, 19 max, 20 disabled 21}: DateInputProps) { 22 const [inputValue, setInputValue] = useState( 23 value ? format(value, 'yyyy-MM-dd') : '' 24 ) 25 26 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { 27 const newValue = e.target.value 28 setInputValue(newValue) 29 30 if (newValue === '') { 31 onChange(null) 32 return 33 } 34 35 const parsed = parse(newValue, 'yyyy-MM-dd', new Date()) 36 if (isValid(parsed)) { 37 if (min && parsed < min) return 38 if (max && parsed > max) return 39 onChange(parsed) 40 } 41 } 42 43 return ( 44 <input 45 type="date" 46 value={inputValue} 47 onChange={handleChange} 48 min={min ? format(min, 'yyyy-MM-dd') : undefined} 49 max={max ? format(max, 'yyyy-MM-dd') : undefined} 50 disabled={disabled} 51 className="rounded-md border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" 52 /> 53 ) 54}

Relative Time Component#

1// components/RelativeTime.tsx 2'use client' 3 4import { useEffect, useState } from 'react' 5import { formatDistanceToNow } from 'date-fns' 6import { formatDateTime } from '@/lib/dates' 7 8interface RelativeTimeProps { 9 date: Date | string 10 updateInterval?: number // milliseconds 11} 12 13export function RelativeTime({ 14 date, 15 updateInterval = 60000 16}: RelativeTimeProps) { 17 const dateObj = typeof date === 'string' ? new Date(date) : date 18 const [relative, setRelative] = useState( 19 formatDistanceToNow(dateObj, { addSuffix: true }) 20 ) 21 22 useEffect(() => { 23 const timer = setInterval(() => { 24 setRelative(formatDistanceToNow(dateObj, { addSuffix: true })) 25 }, updateInterval) 26 27 return () => clearInterval(timer) 28 }, [dateObj, updateInterval]) 29 30 return ( 31 <time 32 dateTime={dateObj.toISOString()} 33 title={formatDateTime(dateObj)} 34 className="text-muted-foreground" 35 > 36 {relative} 37 </time> 38 ) 39}

Date Range Picker#

1// components/DateRangePicker.tsx 2'use client' 3 4import { useState } from 'react' 5import { format } from 'date-fns' 6import { Calendar as CalendarIcon } from 'lucide-react' 7import { DayPicker, DateRange } from 'react-day-picker' 8import { cn } from '@/lib/utils' 9 10interface DateRangePickerProps { 11 value?: DateRange 12 onChange: (range: DateRange | undefined) => void 13 placeholder?: string 14} 15 16export function DateRangePicker({ 17 value, 18 onChange, 19 placeholder = 'Select date range' 20}: DateRangePickerProps) { 21 const [open, setOpen] = useState(false) 22 23 const formatRange = (range?: DateRange) => { 24 if (!range?.from) return placeholder 25 if (!range.to) return format(range.from, 'MMM d, yyyy') 26 return `${format(range.from, 'MMM d')} - ${format(range.to, 'MMM d, yyyy')}` 27 } 28 29 return ( 30 <div className="relative"> 31 <button 32 onClick={() => setOpen(!open)} 33 className={cn( 34 'flex items-center gap-2 rounded-md border px-3 py-2 text-sm', 35 !value?.from && 'text-gray-500' 36 )} 37 > 38 <CalendarIcon className="h-4 w-4" /> 39 {formatRange(value)} 40 </button> 41 42 {open && ( 43 <div className="absolute top-full mt-2 bg-white border rounded-lg shadow-lg z-50"> 44 <DayPicker 45 mode="range" 46 selected={value} 47 onSelect={(range) => { 48 onChange(range) 49 if (range?.from && range?.to) setOpen(false) 50 }} 51 numberOfMonths={2} 52 /> 53 </div> 54 )} 55 </div> 56 ) 57}

Date Validation with Zod#

1// lib/validation/dates.ts 2import { z } from 'zod' 3import { parseISO, isValid, isFuture, isPast, isAfter } from 'date-fns' 4 5// Date string schema 6export const dateStringSchema = z.string().refine( 7 val => isValid(parseISO(val)), 8 { message: 'Invalid date format' } 9) 10 11// Future date schema 12export const futureDateSchema = dateStringSchema.refine( 13 val => isFuture(parseISO(val)), 14 { message: 'Date must be in the future' } 15) 16 17// Past date schema 18export const pastDateSchema = dateStringSchema.refine( 19 val => isPast(parseISO(val)), 20 { message: 'Date must be in the past' } 21) 22 23// Date range schema 24export const dateRangeSchema = z.object({ 25 from: dateStringSchema, 26 to: dateStringSchema 27}).refine( 28 ({ from, to }) => isAfter(parseISO(to), parseISO(from)), 29 { message: 'End date must be after start date' } 30) 31 32// Age validation 33export function validateAge(birthDate: Date, minAge: number): boolean { 34 const today = new Date() 35 const age = today.getFullYear() - birthDate.getFullYear() 36 const monthDiff = today.getMonth() - birthDate.getMonth() 37 38 if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { 39 return age - 1 >= minAge 40 } 41 return age >= minAge 42} 43 44// Usage with forms 45const bookingSchema = z.object({ 46 checkIn: futureDateSchema, 47 checkOut: dateStringSchema 48}).refine( 49 data => isAfter(parseISO(data.checkOut), parseISO(data.checkIn)), 50 { message: 'Check-out must be after check-in', path: ['checkOut'] } 51)

Usage Instructions#

  1. Install date-fns: npm install date-fns date-fns-tz
  2. Create utility file: Add common date functions to lib/dates.ts
  3. Use consistent formatting: Always use the same format patterns
  4. Handle timezones: Store UTC in database, display in user timezone
  5. Validate inputs: Use Zod schemas for date validation

Best Practices#

  1. Store UTC in database - Always store dates in UTC
  2. Display in user timezone - Convert to local time for display
  3. Use ISO strings for API - Transfer dates as ISO 8601 strings
  4. Validate date ranges - Ensure end date is after start date
  5. Handle invalid dates - Check with isValid() before formatting
  6. Use date-fns tree-shaking - Import only what you need
  7. Consider locale - Use locale-aware formatting when needed
  8. Update relative times - Refresh "time ago" displays periodically