Load events with TanStack Query
Fetch the dates the calendar shows with useQuery, and save its changes with useMutation and optimistic updates.
These events come from a pretend server that takes 0.7 seconds to answer. Go to another week or month and the calendar keeps its events on screen while the next ones load. Drag an event and it moves at once, then saves. Tick "Make requests fail" to see the error and the rollback.
Time never fetches or saves events. It tells you which dates it shows through
onVisibleRangeChange, and proposes changes through onEventChange. This guide connects both to
TanStack Query: one query per window of months, and a mutation that
shows a change before it's saved and undoes it if the save fails.
Install
pnpm add @tanstack/react-queryPut a QueryClientProvider above the calendar, as TanStack Query's
quick start does.
The API
The example talks to an API with four requests. Its events are JSON, with ISO strings for times:
import type { CalendarEventColor } from "@/components/calendar/event-colors";
/** An event as the API sends it: JSON, with ISO strings for its times. */
export interface ApiEvent {
readonly id: string;
readonly title: string;
readonly color: CalendarEventColor;
/** Dates such as `2026-10-12` when `allDay`, otherwise instants such as `2026-10-12T07:30:00Z`. */
readonly start: string;
/** Exclusive, as the calendar's ends are. */
readonly end: string;
readonly allDay: boolean;
/** Goes up with every saved change. */
readonly version: number;
}
/** The dates one query loads, from `start` up to `end`, in a time zone. */
export interface EventsWindow {
readonly start: string;
readonly end: string;
readonly timeZone: string;
}
export interface EventsApi {
/** The events that overlap the window, also those that start before it or end after it. */
listEvents(dates: EventsWindow, signal?: AbortSignal): Promise<ApiEvent[]>;
createEvent(event: ApiEvent): Promise<ApiEvent>;
updateEvent(event: ApiEvent): Promise<ApiEvent>;
deleteEvent(id: string): Promise<void>;
}listEvents returns the events that overlap the dates, not only those that start in them, so a
trip that started on Friday still shows on Monday. A timed event overlaps when it starts before the
window's last midnight and ends after its first, in the window's time zone. An all-day event is
compared by its dates:
/**
* Whether an event overlaps the window. Timed events are compared as instants, between the
* window's first and last midnight in its time zone; all-day events as dates.
*/
export function overlapsWindow(event: ApiEvent, { end, start, timeZone }: EventsWindow): boolean {
if (event.allDay) {
return (
Temporal.PlainDate.compare(event.start, end) < 0 &&
Temporal.PlainDate.compare(start, event.end) < 0
);
}
const from = Temporal.PlainDate.from(start).toZonedDateTime(timeZone).toInstant();
const to = Temporal.PlainDate.from(end).toZonedDateTime(timeZone).toInstant();
return (
Temporal.Instant.compare(event.start, to) < 0 && Temporal.Instant.compare(from, event.end) < 0
);
}The preview's server is createFakeEventsApi, in the same file. It keeps events in memory and
answers after a delay. In your app, these four functions call your server.
Read the records as they are
The cache keeps events as the API sends them, and the event adapter reads their times:
import { createCalendar, defineEventAdapter, weekRange } from "@moltenship/calendar";
import { keepPreviousData, usePrefetchQuery, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { calendarAnnouncements } from "@/components/calendar/announcements";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { createMonthView } from "@/components/calendar/month-view/month-view";
import { createDayView, createWeekView } from "@/components/calendar/time-grid-view/time-grid-view";
import { ViewSwitcher } from "@/components/calendar/view-switcher";
import { Button } from "@/components/ui/button";
import type { ApiEvent, EventsApi } from "./query-api";
import { eventsQuery, eventsWindow, shiftWindow, useSaveEvent, writeOf } from "./query-events";
/** Reads the API's records as they are, so the cache can keep plain JSON. */
export const eventAdapter = defineEventAdapter<ApiEvent>()({
getId: (event) => event.id,
getTitle: (event) => event.title,
getInterval: (event) =>
event.allDay
? {
kind: "all-day",
start: Temporal.PlainDate.from(event.start),
end: Temporal.PlainDate.from(event.end),
}
: {
kind: "timed",
start: Temporal.Instant.from(event.start),
end: Temporal.Instant.from(event.end),
},
getResourceIds: () => [],
});
const getEventColor = (event: ApiEvent) => event.color;
export const Calendar = createCalendar<ApiEvent>()({
eventAdapter,
views: [
createWeekView<ApiEvent>({ dayView: "day", getEventColor }),
createDayView<ApiEvent>({ getEventColor }),
createMonthView<ApiEvent>({ getEventColor }),
],
});- Plain JSON in the cache. When a reload brings nothing new, TanStack Query's structural sharing keeps the same array, so the calendar has nothing to redo. JSON also survives dehydration, for server rendering.
- No
getRevision. A change shown before it's saved keeps the record's oldversion. The calendar skips a record whose revision it has seen, so it would keep the old times.
Key the query by the dates
onVisibleRangeChange reports the dates the view shows, range, and the dates to have events
for, loadRange. Keep loadRange in state, and turn it into the window a query loads:
/** Whole months from one date's month to another's. */
const monthsBetween = (from: Temporal.PlainDate, to: Temporal.PlainDate) =>
from.with({ day: 1 }).until(to.with({ day: 1 }), { largestUnit: "months" }).months;
/**
* The window to load for the calendar's `loadRange`: the month holding its middle day, with whole
* months either side, at least one and as many as the load range reaches.
*/
export function eventsWindow(loadRange: PlainDateInterval, timeZone: string): EventsWindow {
const { end, start } = loadRange;
const middle = start.add({ days: Math.floor(start.until(end).days / 2) }).with({ day: 1 });
const months = Math.max(
1,
monthsBetween(start, middle),
monthsBetween(middle, end.subtract({ days: 1 })),
);
return {
end: middle.add({ months: months + 1 }).toString(),
start: middle.subtract({ months }).toString(),
timeZone,
};
}
/** The window `months` later, or earlier when negative. */
export function shiftWindow({ end, start, timeZone }: EventsWindow, months: number): EventsWindow {
return {
end: Temporal.PlainDate.from(end).add({ months }).toString(),
start: Temporal.PlainDate.from(start).add({ months }).toString(),
timeZone,
};
}/** The start of every events query's key, so changes can find them all. */
export const eventsKey = ["events"] as const;
export function eventsQuery(api: EventsApi, dates: EventsWindow) {
return queryOptions({
queryKey: [...eventsKey, dates],
queryFn: ({ signal }) => api.listEvents(dates, signal),
// Going back to dates seen in the last minute shows them without asking again.
staleTime: 60_000,
});
}- Whole months. The calendar's dates change with each step. A key made of them would load again on every step, and cache each week apart. The window is the month that holds the middle of the load range, with a month on either side, so the weeks of a month share one query, and so do a month view's six weeks.
- One month on. When the next week or month moves the middle into the next month, the window
moves by exactly one month, so
shiftWindownames the windows to prefetch. - Long load ranges. A scrolling view loads a page on either side of what it shows. The window grows to hold its load range, such as a scrolling month's eighteen weeks.
- ISO strings and the time zone. The key is JSON, which TanStack Query hashes. The same dates are other instants in another time zone, so the zone is part of the key.
Show what's loading
const weekRules = { firstDay: 1, minimalDays: 4, weekend: [6, 7] } as const;
const noEvents: readonly ApiEvent[] = [];
export interface EventsCalendarProps {
readonly api: EventsApi;
readonly timeZone: string;
}
export function EventsCalendar({ api, timeZone }: EventsCalendarProps) {
const [today] = useState(() => Temporal.Now.plainDateISO(timeZone));
// The calendar reports its dates after its first render, so start from the week it opens on.
const [loadRange, setLoadRange] = useState(() =>
weekRange(today, { weekStartsOn: weekRules.firstDay }),
);
const dates = useMemo(() => eventsWindow(loadRange, timeZone), [loadRange, timeZone]);
// Until the next window loads, keep the last one's events on screen.
const events = useQuery({ ...eventsQuery(api, dates), placeholderData: keepPreviousData });
// Load the windows either side too, so the next or previous month shows at once.
usePrefetchQuery(eventsQuery(api, shiftWindow(dates, -1)));
usePrefetchQuery(eventsQuery(api, shiftWindow(dates, 1)));
const saveEvent = useSaveEvent(api);
return (
<Calendar.Root
events={events.data ?? noEvents}
onVisibleRangeChange={(range) => setLoadRange(range.loadRange)}
// The change shows as pending until the save settles, and rolls back if it fails.
onEventChange={async (change) => {
await saveEvent.mutateAsync(writeOf(change));
}}
defaultAnchorDate={today}
timeZone={timeZone}
locale="en-US"
weekRules={weekRules}
>
<Calendar.Frame className="flex h-full flex-col">
<CalendarToolbar calendar={Calendar} className="border-b">
<LoadingIndicator loading={events.isFetching} />
<CalendarViewSwitcher />
</CalendarToolbar>
{saveEvent.isError ? (
// No live role: the calendar's announcer already says the change was undone.
<div className="flex items-center gap-3 border-b px-4 py-2 text-sm">
Couldn't save the change, so it was undone.
<Button className="ms-auto" onClick={saveEvent.reset} size="sm" variant="outline">
Dismiss
</Button>
</div>
) : events.isError ? (
<div className="flex items-center gap-3 border-b px-4 py-2 text-sm" role="alert">
Couldn't load events.
<Button className="ms-auto" onClick={() => void events.refetch()} size="sm">
Try again
</Button>
</div>
) : null}
<Calendar.Viewport aria-busy={events.isFetching} className="min-h-0 flex-1">
<Calendar.ActiveView />
</Calendar.Viewport>
<Calendar.Announcer messages={calendarAnnouncements} />
</Calendar.Frame>
</Calendar.Root>
);
}LoadingIndicator and CalendarViewSwitcher are small components in the same file: a spinner, and
the registry's view switcher.
- The first render. The calendar reports its dates after it renders, and never on the server,
so the state starts from the week it opens on, from
weekRange. The window rounds both to the same months, so the first load is the one the calendar asks for. If it opens on a month view, start from its grid instead. placeholderData: keepPreviousData. While the next window loads,datakeeps the last window's events, and the dates that both windows hold keep theirs. Without it, the grid would empty for every load.- Loading.
isFetchingturns on the spinner in the toolbar, andaria-busyon the viewport, for every load of the window on screen, also a reload after a save. - Errors. TanStack Query retries a failed load three times by default before
isErrorshows the message; the preview retries once. "Try again" callsrefetch. A window that fails to load has no events to keep, so the grid behind the message is empty. A failed save shows its own message first, since it answers what the user just did, and the calendar announces it. - Strict Mode.
onVisibleRangeChangereports each change once, also when Strict Mode runs effects twice. Setting the same range again would do nothing anyway.
Prefetch the next window
usePrefetchQuery starts loading the windows a month before and after the one on screen, while
the calendar renders. It skips windows the cache already has, so it doesn't load them on every
render. Stepping into the next month then shows its events at once.
Save changes
The calendar proposes changes and your app commits them. onEventChange above hands each change
to a mutation and waits for it. Until the save settles, the change shows as pending; if it fails,
the change rolls back. A change becomes a write: the event's record before and after it:
export type ApiEventChange = CalendarEventChange<ApiEvent, string, never>;
/** What a change writes: the event's record before and after it, or `null` where there's none. */
export interface EventWrite {
readonly id: string;
readonly before: ApiEvent | null;
readonly after: ApiEvent | null;
}
/** `event` at the placement the calendar proposes. */
function placed(
event: Omit<ApiEvent, "allDay" | "end" | "start">,
placement: CalendarEventPlacement<never>,
): ApiEvent {
if (placement.kind === "floating") throw new Error("Events need a time zone.");
return {
...event,
allDay: placement.kind === "all-day",
end: placement.end.toString(),
start: placement.start.toString(),
};
}
export function writeOf(change: ApiEventChange): EventWrite {
switch (change.cause) {
case "create": {
// Picked here, so the event shown while it saves and the saved one share it.
const id = crypto.randomUUID();
const event = { color: "blue", id, title: "New event", version: 0 } as const;
return { after: placed(event, change.proposed), before: null, id };
}
case "delete":
return { after: null, before: change.event, id: change.eventId };
default:
return {
after: placed(change.event, change.proposed),
before: change.event,
id: change.eventId,
};
}
}A new event gets its ID here, so the event shown while it saves and the saved one are the same event, with no temporary ID to swap. The calendar keeps no pending copy of a new event: it shows once it's in the cache. The record goes into every cached window it overlaps:
/**
* Puts `event` in every cached window it overlaps, in place of the event with this `id`, and takes
* that event out of the others. `null` takes it out everywhere.
*/
export function putEvent(queryClient: QueryClient, id: string, event: ApiEvent | null): void {
const cached = queryClient.getQueriesData<ApiEvent[]>({ queryKey: eventsKey });
for (const [queryKey, events] of cached) {
if (events === undefined) continue;
// Keys come from `eventsQuery`: the window follows `eventsKey`.
const dates = queryKey[eventsKey.length] as EventsWindow;
const index = events.findIndex((candidate) => candidate.id === id);
let next = events;
if (event !== null && overlapsWindow(event, dates)) {
next = index === -1 ? [...events, event] : events.with(index, event);
} else if (index !== -1) {
next = events.toSpliced(index, 1);
}
if (next !== events) queryClient.setQueryData(queryKey, next);
}
}And the mutation updates the cache before the save, and puts it back if the save fails:
/**
* Reloads the events after the last save. Loads still on their way read before it, so they're
* cancelled first, and windows off screen that never loaded go, to be prefetched again.
*/
async function reloadEvents(queryClient: QueryClient): Promise<void> {
await queryClient.cancelQueries({ queryKey: eventsKey });
queryClient.removeQueries({
queryKey: eventsKey,
predicate: (query) => query.state.data === undefined,
type: "inactive",
});
await queryClient.invalidateQueries({ queryKey: eventsKey });
}
export function useSaveEvent(api: EventsApi) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: eventsKey,
// Saves reach the server one at a time, in the order they were made. Each shows at once.
scope: { id: "events" },
mutationFn: async ({ after, before, id }: EventWrite) => {
if (after === null) await api.deleteEvent(id);
else if (before === null) await api.createEvent(after);
else await api.updateEvent(after);
},
onMutate: async ({ after, id }) => {
// A reload on its way could bring back what it read before the change. Loads with nothing
// cached yet carry on, and the reload after the save replaces them.
await queryClient.cancelQueries({
queryKey: eventsKey,
predicate: (query) => query.state.data !== undefined,
});
putEvent(queryClient, id, after);
},
// Put the event back as it was, in every window. Other changes that are saving stay.
onError: (_error, { before, id }) => putEvent(queryClient, id, before),
onSettled: (_data, error) => {
// Reload once the last save settles, so a reload between two saves can't undo the second.
if (queryClient.isMutating({ mutationKey: eventsKey }) !== 1) return;
const reload = reloadEvents(queryClient);
// A saved change stays pending until the reload shows it; a failed one rolls back now.
return error === null ? reload : undefined;
},
});
}scopesends the saves one at a time, in the order they were made, so a server never applies an older move after a newer one.onMutatestill runs at once for each.onMutatecancels reloads on their way, which could bring back what they read before the change, and puts the new record in the cache. A move from October to December leaves the windows that held October and shows in those that hold December, before it's saved. Loads of windows with nothing cached yet carry on, so prefetches aren't lost.onErrorputs the old record back the same way. It writes only this event, rather than a copy of the whole cache from before the change, so another change that's still saving stays.onSettledreloads the events once the last save has settled. A reload between two saves would show the first without the second. Loads still on their way read before the save, soreloadEventscancels them, drops windows off screen that never loaded (the ones next to the window on screen are prefetched again), and then marks every events query stale, which reloads the ones on screen. After a save, returning the reload's promise keeps the change pending until the reload shows it. After a failure, it returns nothing, so the change rolls back at once.
If two changes to one event both fail, the second puts back the record the first one left, which the server never had. The next load that succeeds shows what the server has.
mutateAsync rejects when the save fails, after onError has put the old record back, so
onEventChange rejects too and the calendar rolls back the change.
Server rendering
The Next.js guide explains how to render the calendar on the server. With
TanStack Query, prefetch the first window on the server under the same key, and pass the cache to
the browser with TanStack Query's
HydrationBoundary.
The cache holds JSON, not Temporal objects, so it serializes as it is. Start from the same dates on
both sides, so the keys match.
Last updated on