Events and data
Pass events directly or as your own objects, load them per visible range, show loading and errors, and let saved changes settle.
The calendar never owns your data. You pass events in, and when the user changes one, the
calendar tells you (onEventChange) and shows the change until your data has it. There are two
ways to pass events and two ways to load them.
Events
events takes CalendarEventInput objects. Their data field carries anything of yours, and its
type flows into every callback and render function:
'use client'
import type { CalendarEventInput, EventRenderArgs } from '@hapio/calendar-react'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
interface Visit {
readonly customer: string
readonly paid: boolean
}
const events: CalendarEventInput<Visit>[] = [
{
id: 'visit-1',
title: 'Haircut · Anna',
start: '2026-10-22T09:00:00+02:00',
end: '2026-10-22T09:45:00+02:00',
href: '/visits/visit-1',
data: { customer: 'Anna', paid: true },
},
{
id: 'visit-2',
title: 'Colour · Ben',
start: '2026-10-22T10:00:00+02:00',
end: '2026-10-22T11:30:00+02:00',
color: 'oklch(0.55 0.15 150)',
data: { customer: 'Ben', paid: false },
},
]
// `TData` is `Visit` here, inferred from `events`: `event.data` is typed.
function renderEvent({ event, Default }: EventRenderArgs<Visit>) {
if (event.data.paid) return <Default />
return (
<>
<Default />
<span className="sr-only"> (not paid yet)</span>
</>
)
}
export function VisitsCalendar() {
return (
<HapioCalendar
events={events}
renderEvent={renderEvent}
timeZone="Europe/Stockholm"
className="h-[36rem]"
/>
)
}The fields, in short (every field is in the API reference):
| Field | Meaning |
|---|---|
id | required, unique |
start, end | end is exclusive; see the date forms on Your first calendar |
allDay | inferred from the dates' form when left out |
title | the visible text and the start of the accessible name |
href | renders a real <a href> |
color, contrastColor | any CSS color: the fill and the text |
className | on the event element (the <a> itself for links) |
resourceId, resourceIds | the resources it belongs to |
groupId | events with the same group move together |
editable, rules | per-event editing flags and rules (see Editing) |
display | 'none' hides it |
order | breaks ties in the default order (start, longer first, all-day first, order, title) |
recurrence | marks a server-expanded occurrence of a series (see Recurrence) |
Give every event a data when you read it: the type says TData, so an event without one would
surprise your render function.
Keep identities stable. The calendar normalizes events per array, so pass the same array while
nothing changed (state, a query's data, a module constant, useMemo), never a new literal on every
render. A new array is new data: it is normalized again, and it settles any saved change that
waits for its echo (see below).
Your own objects: accessor mode
When your data already has a shape, pass the objects as they are with eventItems and say how to
read each one with eventAccessor. event.data is then the object itself:
'use client'
import type { CalendarEventInputBase, EventClickArgs } from '@hapio/calendar-react'
import { useRouter } from 'next/navigation'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import type { Booking } from '@/lib/bookings'
/** A booking as the calendar reads it (module scope: one identity for the app's lifetime). */
function toCalendarEvent(booking: Booking): CalendarEventInputBase {
return {
id: booking.id,
title: booking.title,
start: booking.startsAt,
end: booking.endsAt,
resourceId: booking.staffId,
className: booking.status === 'canceled' ? 'line-through opacity-70' : undefined,
}
}
export function BookingsCalendar({ bookings }: { bookings: readonly Booking[] }) {
const router = useRouter()
// `event.data` is the booking itself, typed: no copying into `data`.
const onEventClick = ({ event }: EventClickArgs<Booking>) => {
router.push(`/bookings/${event.data.id}`)
}
return (
<HapioCalendar
eventItems={bookings}
eventAccessor={toCalendarEvent}
onEventClick={onEventClick}
timeZone="Europe/Stockholm"
className="h-[36rem]"
/>
)
}Define the accessor once, outside the component, so its identity stays the same. events and
eventItems are exclusive; the TypeScript types say so.
Loading by range: event sources
An event source fetches the events of the visible range. The calendar calls it when the range, the
time zone or the source's window changes, aborts requests it no longer needs, and keeps showing
the previous range's events while the next ones load:
'use client'
import type { CalendarEventInput, EventSource } from '@hapio/calendar-react'
import { useState } from 'react'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import { fetchBookings, type Booking } from '@/lib/bookings'
const SOURCES: readonly EventSource<CalendarEventInput<Booking>>[] = [
{
id: 'bookings',
// Fetch whole weeks: moving between the days of a loaded week fetches nothing.
window: 'week',
async fetch(range, { signal }) {
// `signal` aborts when the user navigates on, the zone changes or the calendar unmounts.
const bookings = await fetchBookings(range.startIso, range.endIso, signal)
return bookings.map((booking) => ({
id: booking.id,
title: booking.title,
start: booking.startsAt,
end: booking.endsAt,
data: booking,
}))
},
},
]
export function StudioCalendar() {
const [failed, setFailed] = useState(false)
return (
<>
<HapioCalendar
eventSources={SOURCES}
onSourceError={() => setFailed(true)}
onLoadingChange={(loading) => {
if (loading) setFailed(false)
}}
timeZone="Europe/Stockholm"
className="h-[36rem]"
/>
{failed ? <p role="alert">The bookings could not be loaded.</p> : null}
</>
)
}rangehasstartandend(Temporal.ZonedDateTime, the end exclusive) and the same instants as ISO strings with offset,startIsoandendIso, plustimeZone. The ISO strings never need theTemporalglobal, so a source can run before the polyfill matters.window: 'week'or'month'widens each request to whole weeks or months in the calendar's zone, so navigating inside it fetches nothing new. The default is'view', the visible range.keepPrevious: falseshows nothing from the source until its new result arrives.color,className,editableandrulesapply to every event of the source; the event's own values win over the source's, which win over its resource's and the calendar's.- Sources are keyed by
id. The calendar readsfetchwhen it calls it, so a new object with the sameidrefetches nothing;commands.refetch(id)(orrefetch()for every source) does.commandscomes fromuseCalendarContext()in a custom toolbar, or fromuseCalendarin markup of your own.
In accessor mode a source returns your objects instead of event inputs. Background events load the
same way with backgroundSources.
Loading and errors
While any source loads, the block draws a thin progress bar under the header and marks the view
aria-busy (never the root, which holds the live region whose announcements a busy region could
hold back). onLoadingChange(loading) tells you when loading starts and stops, and
onSourceError(error, sourceId) when a request fails (aborted requests are not errors). Pass
loading to show your own loading state the same way, for example a query's isFetching.
Loading with a query library
If your app loads data with a query library, let it own the requests and pass the result. Two
things matter: the query key follows the range the calendar reports with onRangeChange, and a
saved change reaches the data the calendar reads. With TanStack Query:
'use client'
import {
sameTimeZone,
type CalendarEventInputBase,
type DefaultViews,
type EventChange,
type RangeChange,
type ViewName,
} from '@hapio/calendar-react'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { CalendarEditing } from '@/components/hapio-calendar/calendar-editing'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
import { fetchBookings, rescheduleBooking, type Booking } from '@/lib/bookings'
type Range = RangeChange<ViewName<DefaultViews>>
const NO_BOOKINGS: readonly Booking[] = []
function toCalendarEvent(booking: Booking): CalendarEventInputBase {
return {
id: booking.id,
title: booking.title,
start: booking.startsAt,
end: booking.endsAt,
resourceId: booking.staffId,
}
}
export function BookingsCalendar({ timeZone }: { timeZone: string }) {
const queryClient = useQueryClient()
const [range, setRange] = useState<Range | null>(null)
// One query per visible range; the previous range's bookings stay while the next ones load.
const bookings = useQuery({
queryKey: ['bookings', range?.timeZone, range?.startIso, range?.endIso],
queryFn: ({ signal }) =>
range === null ? Promise.resolve([]) : fetchBookings(range.startIso, range.endIso, signal),
// After a zone change, wait for the range in the new zone.
enabled: range !== null && sameTimeZone(range.timeZone, timeZone),
placeholderData: keepPreviousData,
})
const reschedule = useMutation({
mutationFn: (change: EventChange<Booking>) =>
rescheduleBooking(
change.event.data.id,
{
startsAt: change.next.startIso,
endsAt: change.next.endIso,
staffId: change.next.resourceIds[0] ?? null,
},
change.signal, // aborted when a newer change to the booking supersedes this one
),
// The echo: the saved booking replaces the old one in every cached range, so the calendar's
// data changes and the pending change settles right away.
onSuccess: (saved) => {
queryClient.setQueriesData<Booking[]>({ queryKey: ['bookings'] }, (old) =>
old?.map((booking) => (booking.id === saved.id ? saved : booking)),
)
},
// Then refetch, in case the server changed more than this booking.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['bookings'] }),
})
return (
<CalendarEditing>
<HapioCalendar
timeZone={timeZone}
onRangeChange={setRange}
eventItems={bookings.data ?? NO_BOOKINGS}
eventAccessor={toCalendarEvent}
loading={bookings.isFetching}
editable
onEventChange={async (change) => {
// Resolving accepts the change; a rejection reverts it.
await reschedule.mutateAsync(change)
}}
className="h-[max(36rem,calc(100svh-17rem))]"
/>
</CalendarEditing>
)
}onRangeChangefires once per visible range after it renders (the view,startIso,endIsoandtimeZone; deduplicated, so React's StrictMode does not double it), and once more when atimeZonechange makes a new range.placeholderData: keepPreviousDatakeeps the last range's bookings visible while the next ones load, as event sources do.- The
onSuccessupdate is the echo the calendar waits for: see the next section.
Saved changes settle on your data
When onEventChange (or onExternalDrop) accepts a change, the calendar keeps drawing the event
where it was dropped, marked pending, until one of three things happens:
- The echo: the event's own input changes (your data now has the new times). The calendar then shows your data, which agrees.
- New data: any new
events,eventItemsor source result arrives after the change was accepted. Recurringseriesexpanded over a newly visible range are not new data; a newseriesarray is. - The timeout:
settleTimeoutMs(15 seconds by default) passes. The calendar then shows your data as it is, so an event whose save never reached your data moves back, and development builds warn that the app accepted a change without updating its data.
An echo that arrives before your promise resolves (as with the onSuccess update above) settles
the change as soon as it resolves. A change you reject (false, a throw or a rejected promise)
goes back at once. Editing describes the whole cycle.
Background events, business hours and resources
import type { BackgroundEventInput, CalendarEventInput, ResourceInput } from '@hapio/calendar-react'
import { HapioCalendar } from '@/components/hapio-calendar/hapio-calendar'
const staff: ResourceInput[] = [
{ id: 'anna', title: 'Anna' },
{ id: 'ben', title: 'Ben' },
]
// Shading with a name: `label` makes it an image screen readers can find ("Open · …").
const openTime: BackgroundEventInput[] = [
{
id: 'open-mon',
start: '2026-10-19T09:00:00+02:00',
end: '2026-10-19T17:00:00+02:00',
group: 'open',
label: 'Open · Mon 19 Oct 09:00–17:00',
},
]
// Weekdays 08:00–18:00; the rest of each day is shaded.
const businessHours = { days: [1, 2, 3, 4, 5], start: '08:00', end: '18:00' } as const
const bookings: CalendarEventInput[] = [
{
id: 'b1',
title: 'Haircut',
start: '2026-10-19T10:00:00+02:00',
end: '2026-10-19T10:45:00+02:00',
resourceId: 'anna', // its description names Anna
},
]
export function StaffCalendar() {
return (
<HapioCalendar
events={bookings}
resources={staff}
backgroundEvents={openTime}
businessHours={businessHours}
timeZone="Europe/Stockholm"
className="h-[36rem]"
/>
)
}- Background events shade a range in the time and day grids. With a
labelthey are images with that name for screen readers; without one they are hidden from them.groupmakes them a rule target:rules.select.constraint: { group: 'open' }keeps selections inside them, and the same works for moves (see Selection).inverse: trueshades everything outside the group's ranges instead. businessHoursshades the time outside the hours you give (trueis Monday to Friday 09:00–17:00), and the'businessHours'constraint keeps selections and moves inside them.- Resources are the people, rooms or chairs events belong to. Their titles go into each
event's description ("…, 10:00 to 10:45, Anna"), and the "Change resource…" form moves events
between them. Views with a lane per resource arrive in 1.3; events written for 1.0 carry their
resourceIdalready.