React Event Calendar

Architecture

Understanding the architecture and design patterns of React Event Calendar.

Overview

React Event Calendar is built with a modern architecture using Next.js App Router, Zustand for state management, URL state management with nuqs, and PostgreSQL with Drizzle ORM for data persistence. The calendar supports multiple views (day, week, month, year) with customizable configurations.

src/

State Management

The calendar uses a combination of Zustand for global state management and URL state management with nuqs to enable shareable calendar views.

URL State with nuqs

URL parameters are managed with nuqs to enable shareable calendar views and bookmarking:

lib/searchParams.ts
1import { CalendarViewType } from '@/types/event'; 2import { 3 createSearchParamsCache, 4 parseAsArrayOf, 5 parseAsBoolean, 6 parseAsInteger, 7 parseAsIsoDate, 8 parseAsString, 9} from 'nuqs/server'; 10 11export const searchParamsCache = createSearchParamsCache({ 12 date: parseAsIsoDate.withDefault(new Date()), 13 view: parseAsString.withDefault(CalendarViewType.MONTH), 14 title: parseAsString.withDefault(''), 15 categories: parseAsArrayOf(parseAsString).withDefault([]), 16 daysCount: parseAsInteger.withDefault(7), 17 search: parseAsString.withDefault(''), 18 colors: parseAsArrayOf(parseAsString).withDefault([]), 19 locations: parseAsArrayOf(parseAsString).withDefault([]), 20 repeatingTypes: parseAsArrayOf(parseAsString).withDefault([]), 21 isRepeating: parseAsBoolean.withDefault(false), 22 limit: parseAsInteger.withDefault(50), 23 offset: parseAsInteger.withDefault(0), 24}); 25

Zustand Store

The core state is managed by a Zustand store that handles view configurations, selected events, and UI states:

hooks/use-event-calendar.tsx
1import { create } from 'zustand'; 2import { persist } from 'zustand/middleware'; 3import { 4 CalendarViewType, 5 TimeFormatType, 6 ViewModeType, 7} from '@/types/event'; 8 9const DEFAULT_VIEW_CONFIGS = { 10 day: { 11 showCurrentTimeIndicator: true, 12 showHoverTimeIndicator: true, 13 enableTimeSlotClick: true, 14 }, 15 week: { 16 highlightToday: true, 17 showCurrentTimeIndicator: true, 18 showHoverTimeIndicator: true, 19 enableTimeSlotClick: true, 20 expandMultiDayEvents: true, 21 }, 22 // ... other view configurations 23}; 24 25export const useEventCalendarStore = create( 26 persist( 27 (set, get) => ({ 28 selectedEvent: null, 29 currentView: CalendarViewType.DAY, 30 viewMode: ViewModeType.CALENDAR, 31 timeFormat: TimeFormatType.HOUR_24, 32 locale: 'en-US', 33 firstDayOfWeek: 0, // sunday 34 daysCount: 7, 35 viewSettings: DEFAULT_VIEW_CONFIGS, 36 // ... other state properties 37 38 setView: (view) => set({ currentView: view }), 39 setTimeFormat: (format) => set({ timeFormat: format }), 40 // ... other actions 41 }), 42 { 43 name: 'event-calendar', 44 partialize: (state) => ({ 45 currentView: state.currentView, 46 viewMode: state.viewMode, 47 timeFormat: state.timeFormat, 48 locale: state.locale, 49 firstDayOfWeek: state.firstDayOfWeek, 50 daysCount: state.daysCount, 51 viewSettings: state.viewSettings, 52 }), 53 } 54 ) 55);

Database Structure

The calendar uses PostgreSQL with Drizzle ORM for data persistence. The schema is designed to support various event properties including recurring events.

db/schema.ts
1import { 2 pgTable, 3 timestamp, 4 varchar, 5 uuid, 6 boolean, 7 text, 8} from 'drizzle-orm/pg-core'; 9 10export const events = pgTable('events', { 11 id: uuid('id').primaryKey().defaultRandom(), 12 title: varchar('title', { length: 256 }).notNull(), 13 description: text('description').notNull(), 14 startDate: timestamp('start_date', { withTimezone: true }).notNull(), 15 endDate: timestamp('end_date', { withTimezone: true }).notNull(), 16 startTime: varchar('start_time', { length: 5 }).notNull(), 17 endTime: varchar('end_time', { length: 5 }).notNull(), 18 isRepeating: boolean('is_repeating').notNull(), 19 repeatingType: varchar('repeating_type', { 20 length: 10, 21 enum: ['daily', 'weekly', 'monthly'], 22 }).$type<'daily' | 'weekly' | 'monthly'>(), 23 location: varchar('location', { length: 256 }).notNull(), 24 category: varchar('category', { length: 100 }).notNull(), 25 color: varchar('color', { length: 15 }).notNull(), 26 createdAt: timestamp('created_at', { withTimezone: true }) 27 .defaultNow() 28 .notNull(), 29 updatedAt: timestamp('updated_at', { withTimezone: true }) 30 .defaultNow() 31 .notNull(), 32}); 33 34export type EventTypes = typeof events.$inferSelect; 35export type newEvent = typeof events.$inferInsert;

Server Actions

Next.js Server Actions are used to interact with the database, providing type-safe data fetching and mutations:

app/actions.ts
1'use server'; 2 3import { db } from '@/db'; 4import { events } from '@/db/schema'; 5import { CalendarViewType } from '@/types/event'; 6import { and, between, eq, ilike, or, lte, gte } from 'drizzle-orm'; 7import { 8 startOfDay, 9 endOfDay, 10 startOfWeek, 11 endOfWeek, 12 startOfMonth, 13 endOfMonth, 14 startOfYear, 15 endOfYear, 16} from 'date-fns'; 17import { z } from 'zod'; 18import { unstable_cache as cache, revalidatePath } from 'next/cache'; 19 20const eventFilterSchema = z.object({ 21 title: z.string().optional(), 22 categories: z.array(z.string()).default([]), 23 daysCount: z.number().optional(), 24 view: z.enum([ 25 CalendarViewType.DAY, 26 CalendarViewType.DAYS, 27 CalendarViewType.WEEK, 28 CalendarViewType.MONTH, 29 CalendarViewType.YEAR, 30 ]).optional(), 31 date: z.date(), 32 // ... other filter properties 33}); 34 35export type EventFilter = z.infer<typeof eventFilterSchema>; 36 37export const getEvents = cache( 38 async (filterParams: EventFilter) => { 39 try { 40 const filter = eventFilterSchema.parse(filterParams); 41 42 const currentDate = new Date(filter.date); 43 let dateRange: { start: Date; end: Date } = { 44 start: startOfMonth(currentDate), 45 end: endOfMonth(currentDate), 46 }; 47 48 if (filter.view) { 49 switch (filter.view) { 50 case CalendarViewType.DAY: 51 dateRange = { 52 start: startOfDay(currentDate), 53 end: endOfDay(currentDate), 54 }; 55 break; 56 case CalendarViewType.WEEK: 57 dateRange = { 58 start: startOfWeek(currentDate, { weekStartsOn: 0 }), 59 end: endOfWeek(currentDate, { weekStartsOn: 0 }), 60 }; 61 break; 62 // ... other view cases 63 } 64 } 65 66 const conditions = []; 67 68 // Add date range condition 69 conditions.push( 70 or( 71 and( 72 between(events.startDate, dateRange.start, dateRange.end), 73 between(events.endDate, dateRange.start, dateRange.end), 74 ), 75 // ... other date conditions 76 ), 77 ); 78 79 // Add other filter conditions 80 if (filter.title) { 81 conditions.push(ilike(events.title, `%${filter.title}%`)); 82 } 83 84 if (filter.categories.length > 0) { 85 const categoryConditions = filter.categories.map((category) => 86 eq(events.category, category), 87 ); 88 conditions.push(or(...categoryConditions)); 89 } 90 91 // Execute the query with all conditions 92 const result = await db 93 .select() 94 .from(events) 95 .where(and(...conditions)) 96 .execute(); 97 98 return { 99 events: result, 100 success: true, 101 timestamp: new Date().toISOString(), 102 }; 103 } catch (error) { 104 console.error('Error fetching events:', error); 105 return { 106 events: [], 107 success: false, 108 error: error instanceof Error ? error.message : 'Error fetching events', 109 }; 110 } 111 }, 112 ['get-events'], 113 { 114 revalidate: 3600, 115 tags: ['events'], 116 } 117);

Component Architecture

The calendar is built with a modular component architecture that separates concerns:

Main Components

  • EventCalendar: The main container component that manages state and renders the appropriate view
  • CalendarHeader: Navigation controls, view switchers, and date display
  • CalendarDayView: Single day detailed view with time slots
  • CalendarWeekView: Week view showing multiple days with time slots
  • CalendarMonthView: Traditional month grid view
  • EventItem: Individual event rendering with positioning logic
  • EventForm: Form for creating and editing events
components/event-calendar/calendar.tsx
1import { CalendarHeader } from './calendar-header'; 2import { CalendarDayView } from './calendar-day-view'; 3import { CalendarWeekView } from './calendar-week-view'; 4import { CalendarMonthView } from './calendar-month-view'; 5import { useEventCalendarStore } from '@/hooks/use-event-calendar'; 6import { CalendarViewType } from '@/types/event'; 7import { EventTypes } from '@/db/schema'; 8 9interface EventCalendarProps { 10 events: EventTypes[]; 11 initialDate: Date; 12} 13 14export function EventCalendar({ events, initialDate }: EventCalendarProps) { 15 const currentView = useEventCalendarStore((state) => state.currentView); 16 17 // Render different views based on the current view state 18 const renderCalendarView = () => { 19 switch (currentView) { 20 case CalendarViewType.DAY: 21 return <CalendarDayView events={events} date={initialDate} />; 22 case CalendarViewType.WEEK: 23 return <CalendarWeekView events={events} date={initialDate} />; 24 case CalendarViewType.MONTH: 25 return <CalendarMonthView events={events} date={initialDate} />; 26 default: 27 return <CalendarDayView events={events} date={initialDate} />; 28 } 29 }; 30 31 return ( 32 <div className="flex flex-col h-full"> 33 <CalendarHeader /> 34 {renderCalendarView()} 35 </div> 36 ); 37}

Data Flow

The calendar follows a unidirectional data flow pattern with Next.js App Router and Server Actions:

  1. URL State: User interactions update URL parameters via nuqs
  2. Server Actions: URL parameters are parsed and used to fetch data from the database
  3. Component Rendering: Data is passed to components for rendering
  4. User Interactions: UI events trigger state updates and Server Actions
  5. Persistence: Changes are saved to the database via Server Actions and the UI is updated
app/page.tsx
1import { getEvents, getCategories } from './actions'; 2import { searchParamsCache } from '@/lib/searchParams'; 3import { CalendarViewType } from '@/types/event'; 4import { EventCalendar } from '@/components/event-calendar/calendar'; 5import { Suspense } from 'react'; 6 7export default async function CalendarPage({ searchParams }) { 8 // Parse URL parameters using nuqs 9 const search = searchParamsCache.parse(searchParams); 10 11 // Fetch data using Server Actions 12 const eventsResponse = await getEvents({ 13 date: search.date, 14 view: search.view as CalendarViewType, 15 daysCount: Number(search.daysCount), 16 categories: search.categories, 17 title: search.title, 18 colors: search.colors, 19 locations: search.locations, 20 isRepeating: search.isRepeating, 21 }); 22 23 // Render the calendar with the fetched data 24 return ( 25 <div className="container mx-auto p-4"> 26 <Suspense fallback={<div>Loading calendar...</div>}> 27 <EventCalendar 28 events={eventsResponse.events} 29 initialDate={search.date} 30 /> 31 </Suspense> 32 </div> 33 ); 34}

Event Filtering and Search

The calendar supports advanced filtering and search capabilities:

  • Date Range Filtering: Events are filtered based on the selected view (day, week, month, year)
  • Category Filtering: Events can be filtered by category
  • Color Filtering: Events can be filtered by color
  • Location Filtering: Events can be filtered by location
  • Recurring Event Filtering: Events can be filtered by their recurring status
  • Text Search: Events can be searched by title, description, or location

All filters can be combined to create complex queries, and the results are efficiently fetched from the database using Drizzle ORM's query builder.

Recurring Events

The calendar supports recurring events with different patterns:

  • Daily: Events that repeat every day
  • Weekly: Events that repeat on the same day every week
  • Monthly: Events that repeat on the same day every month

Recurring events are stored in the database with a flag indicating their recurring status and the type of recurrence. The calendar logic handles the expansion of recurring events when displaying them in different views.

Performance Optimizations

The calendar implements several performance optimizations:

  • Server-Side Rendering: Initial data is rendered on the server for faster page loads
  • Data Caching: Server Actions use Next.js caching to reduce database queries
  • Incremental Static Regeneration: Pages are statically generated and revalidated periodically
  • Optimized Database Queries: Queries are optimized to fetch only the necessary data
  • Component Memoization: React components use memoization to prevent unnecessary re-renders