Hapio Calendar

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:

components/visits-calendar.tsx
'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):

FieldMeaning
idrequired, unique
start, endend is exclusive; see the date forms on Your first calendar
allDayinferred from the dates' form when left out
titlethe visible text and the start of the accessible name
hrefrenders a real <a href>
color, contrastColorany CSS color: the fill and the text
classNameon the event element (the <a> itself for links)
resourceId, resourceIdsthe resources it belongs to
groupIdevents with the same group move together
editable, rulesper-event editing flags and rules (see Editing)
display'none' hides it
orderbreaks ties in the default order (start, longer first, all-day first, order, title)
recurrencemarks 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:

components/bookings-calendar.tsx
'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:

components/studio-calendar.tsx
'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}
    </>
  )
}
  • range has start and end (Temporal.ZonedDateTime, the end exclusive) and the same instants as ISO strings with offset, startIso and endIso, plus timeZone. The ISO strings never need the Temporal global, 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: false shows nothing from the source until its new result arrives.
  • color, className, editable and rules apply 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 reads fetch when it calls it, so a new object with the same id refetches nothing; commands.refetch(id) (or refetch() for every source) does. commands comes from useCalendarContext() in a custom toolbar, or from useCalendar in 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:

components/bookings-calendar.tsx
'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>
  )
}
  • onRangeChange fires once per visible range after it renders (the view, startIso, endIso and timeZone; deduplicated, so React's StrictMode does not double it), and once more when a timeZone change makes a new range.
  • placeholderData: keepPreviousData keeps the last range's bookings visible while the next ones load, as event sources do.
  • The onSuccess update 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:

  1. The echo: the event's own input changes (your data now has the new times). The calendar then shows your data, which agrees.
  2. New data: any new events, eventItems or source result arrives after the change was accepted. Recurring series expanded over a newly visible range are not new data; a new series array is.
  3. 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

components/staff-calendar.tsx
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 label they are images with that name for screen readers; without one they are hidden from them. group makes them a rule target: rules.select.constraint: { group: 'open' } keeps selections inside them, and the same works for moves (see Selection). inverse: true shades everything outside the group's ranges instead.
  • businessHours shades the time outside the hours you give (true is 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 resourceId already.