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#
- Install date-fns:
npm install date-fns date-fns-tz - Create utility file: Add common date functions to
lib/dates.ts - Use consistent formatting: Always use the same format patterns
- Handle timezones: Store UTC in database, display in user timezone
- Validate inputs: Use Zod schemas for date validation
Best Practices#
- Store UTC in database - Always store dates in UTC
- Display in user timezone - Convert to local time for display
- Use ISO strings for API - Transfer dates as ISO 8601 strings
- Validate date ranges - Ensure end date is after start date
- Handle invalid dates - Check with
isValid()before formatting - Use date-fns tree-shaking - Import only what you need
- Consider locale - Use locale-aware formatting when needed
- Update relative times - Refresh "time ago" displays periodically
Related Patterns#
- Formatting - Number and text formatting
- Validation - Input validation with Zod
- Forms - Form handling with dates