Custom views
Define a view with a pure strategy and a Renderer component.
A view has two halves:
- a strategy: four plain functions that pick the visible dates, move them, title them, and turn the events into whatever the view needs to draw (its projection);
- a Renderer: a React component that receives that projection.
Root runs the strategy and passes the result to the Renderer, so the Renderer only draws.
Define a view
This view shows a few days at a time and counts the meetings on each one. Everything it imports
comes from @moltenship/calendar.
import {
createCalendar,
defineCalendarView,
iteratePlainDates,
plainDateInterval,
zonedDayRange,
} from "@moltenship/calendar";
import { eventAdapter, meetings, weekRules, type Meeting } from "./quick-start";
export const daysView = defineCalendarView({
name: "days",
defaultOptions: { days: 3 },
strategy: {
getRange: (anchor, _environment, { days }) => plainDateInterval(anchor, anchor.add({ days })),
navigate: (anchor, delta, { days }) => anchor.add({ days: delta * days }),
getTitle: (range, { locale }) =>
new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" }).formatRange(
range.start,
range.end.subtract({ days: 1 }),
),
project: ({ snapshot, range, environment }) =>
Array.from(iteratePlainDates(range), (date) => {
const day = zonedDayRange(date, environment.timeZone);
return { date, events: day.interval ? snapshot.queryTimed(day.interval) : [] };
}),
},
Renderer: ({ projection, actions }) => (
<ol>
{projection.map(({ date, events }) => (
<li key={date.toString()}>
<button type="button" onClick={() => actions.setAnchorDate(date)}>
{date.toString()}
</button>{" "}
{events.length} meetings
</li>
))}
</ol>
),
});Write strategy before Renderer. TypeScript reads the object in order, so the Renderer's
projection gets its type from project.
Register it and set options
Pass views to createCalendar. View names must be unique; createCalendar throws on a duplicate.
viewOptions on Root overrides a view's defaultOptions, keyed by view name:
export const Calendar = createCalendar<Meeting>()({
eventAdapter,
views: [daysView],
});
export function WorkWeek() {
return (
<Calendar.Root
events={meetings}
defaultAnchorDate={Temporal.PlainDate.from("2026-10-05")}
defaultTimeZone="Europe/Berlin"
locale="en-US"
weekRules={weekRules}
viewOptions={{ days: { days: 5 } }}
>
<Calendar.Title />
<Calendar.ActiveView />
</Calendar.Root>
);
}The title reads "Oct 5 – 9", and the list has five days.
Strategy
Prop
Type
anchor is the date in the anchorDate state. environment holds locale, timeZone,
weekRules, direction, and disambiguation. The functions should be pure: Root calls them
during render and reuses the projection until one of its inputs changes.
project receives:
| Field | Contents |
|---|---|
snapshot | The validated, indexed events. Query it with queryTimed and friends. |
range | The visible dates, from getRange |
instantRange | The same dates as instants in the calendar's time zone, ready for queryTimed |
loadRange | The dates to project: getLoadRange's, or range without it. Always set by Root |
environment | Locale, time zone, week rules, direction, disambiguation |
resources | The resources prop |
options | The view's options after viewOptions overrides |
today | Today's date in the calendar's time zone, or null before a clock is known |
today is fixed when Root has now, so server and browser agree. Without now it's null
during server rendering and fills in after hydration.
Typed events
Inside project, events are typed unknown unless you say otherwise, because a view can be used by
calendars with different records. To work with your own fields, annotate the input:
import type { CalendarViewProjectionInput } from "@moltenship/calendar";
project: ({ snapshot, instantRange }: CalendarViewProjectionInput<DaysOptions, Meeting>) =>
snapshot.queryTimed(instantRange).map(({ event }) => event.title),Renderer
The Renderer is a regular component, so it can use hooks. It receives:
| Prop | Contents |
|---|---|
projection | What project returned |
range | The visible dates |
loadRange | The dates project covered: range, or the strategy's wider getLoadRange |
options | The view's options |
environment | Locale, time zone, week rules, direction, disambiguation |
events | The snapshot project received, with pending changes applied |
actions | setAnchorDate, setView, navigateNext, navigatePrevious, navigateToday, proposeEventChange |
actions never changes identity. setView takes any registered view name; an unknown name shows up
as an unregistered-active-view diagnostic. proposeEventChange and events are what a view needs
to let people drag and resize events.
To declare a Renderer as its own component, type its props with CalendarViewRendererProps:
import type { CalendarViewRendererProps } from "@moltenship/calendar";
function DaysRenderer({ projection }: CalendarViewRendererProps<DaysProjection, DaysOptions>) {
// …
}Reading the view from other components
Components inside Root can read the active view without being part of it:
export function VisibleRange() {
const range = Calendar.useVisibleRange();
if (range === null) return null;
return (
<p>
{range.start.toString()} to {range.end.subtract({ days: 1 }).toString()}
</p>
);
}Calendar.useVisibleRange()returns the visible dates, ornullwhen the active view isn't registered.Calendar.useViewProjection()returns the active view'sviewname,definition,projection,range,instantRange,loadRange,loadInstantRange,options,environment, andevents, ornull. Checkviewto narrowprojectionto that view's type.useNow({ intervalMs })returns the current instant for a now-line and re-renders on each tick. It'snullduring server rendering and hydration unlessRoothasnow.
Last updated on