02examples
Lazy-load by date range
A controlled calendar that only holds what onRangeChange has fetched, merged page by page, with edits written back to the server copy.
The server holds about 300 events spread over sixteen weeks. The calendar holds none of them until it asks: on mount it reports the visible range and the page arrives 350 ms later. Step with ‹ › or switch to month view and watch the status line — every navigation is a request, the count of loaded events only ever grows, and a week you have already seen is drawn from memory while its refresh is in flight. Drag something, leave, come back: the edit is still there, because it was written through to the server.
liveLazy-load by date rangeonRangeChangerangePaddingeventsonEventsChange
loading
"use client";
import { useCallback, useRef, useState } from "react";
import { Kloq, type CalEvent } from "kloq";
import { cn } from "@/lib/utils";
import { EventServer } from "./data";
interface Request {
id: number;
start: Date;
end: Date;
/** null while in flight */
count: number | null;
}
const day = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
// `end` is exclusive — midnight after the last day — so the label shows the day before it
const span = (r: Request) => `${day(r.start)} – ${day(new Date(r.end.getTime() - 1))}`;
const mergeById = (loaded: CalEvent[], page: CalEvent[]) => {
const byId = new Map(loaded.map((e) => [e.id, e]));
for (const e of page) byId.set(e.id, e);
return [...byId.values()];
};
export default function RangeFetchingExample() {
const [server] = useState(() => new EventServer(new Date()));
const [events, setEvents] = useState<CalEvent[]>([]);
const [requests, setRequests] = useState<Request[]>([]);
const last = useRef("");
const seq = useRef(0);
const onRangeChange = useCallback(
(start: Date, end: Date) => {
const key = `${start.getTime()}:${end.getTime()}`;
if (key === last.current) return; // React StrictMode replays the mount call in development
last.current = key;
const id = ++seq.current;
setRequests((rs) => [{ id, start, end, count: null }, ...rs]);
void server.fetchRange(start, end).then((page) => {
setEvents((loaded) => mergeById(loaded, page));
setRequests((rs) => rs.map((r) => (r.id === id ? { ...r, count: page.length } : r)));
});
},
[server],
);
const onEventsChange = (next: CalEvent[]) => {
server.sync(events, next);
setEvents(next);
};
const inFlight = requests.some((r) => r.count === null);
const latest = requests.find((r) => r.count !== null);
return (
<div className="flex flex-col">
<div className="h-[26rem]">
<Kloq
events={events}
onEventsChange={onEventsChange}
onRangeChange={onRangeChange}
rangePadding={7}
storage={false}
designMode={false}
responsive={false}
>
<Kloq.Toolbar />
</Kloq>
</div>
<div className="min-h-28 space-y-1 border-t px-4 py-2 font-mono text-xs text-muted-foreground tabular-nums">
<p className="flex items-center gap-2 text-foreground">
<span
className={cn(
"size-1.5 rounded-full",
inFlight ? "bg-red-500 motion-safe:animate-pulse" : "bg-border",
)}
/>
{latest
? `fetched ${span(latest)} · ${events.length} of ${server.size} events loaded · ${requests.length} requests`
: "fetching…"}
</p>
{requests.slice(0, 3).map((r) => (
<p key={r.id} className="flex gap-3">
<span>#{r.id}</span>
<span>{span(r)}</span>
<span>{r.count === null ? "…" : `${r.count} events`}</span>
</p>
))}
</div>
</div>
);
}
import {
formatInstant,
instantPlus,
parseInstant,
toDateOnlyString,
type CalEvent,
type EventColor,
} from "kloq";
const TITLES = [
"Standup", "Design review", "1:1", "Deep work", "Customer call", "Planning",
"Retro", "Interview", "Lunch", "Pairing", "Demo", "Roadmap",
];
const COLORS: EventColor[] = ["blue", "violet", "emerald", "amber", "rose"];
/** mulberry32 — a seeded generator, so every visitor gets the same 300 events. */
function prng(seed: number) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** ~300 events over the 16 weeks around `now`, weekends mostly empty. */
export function generateEvents(now: Date, count = 300): CalEvent[] {
const rand = prng(20260830);
const first = new Date(now);
first.setHours(0, 0, 0, 0);
first.setDate(first.getDate() - 8 * 7);
const events: CalEvent[] = [];
while (events.length < count) {
const day = new Date(first);
day.setDate(first.getDate() + Math.floor(rand() * 16 * 7));
if (day.getDay() % 6 === 0 && rand() < 0.8) continue;
const id = `ev-${events.length}`;
const title = TITLES[Math.floor(rand() * TITLES.length)];
const color = COLORS[events.length % COLORS.length];
if (rand() < 0.06) {
const next = new Date(day);
next.setDate(day.getDate() + 1);
events.push({ id, title, color, allDay: true, start: toDateOnlyString(day), end: toDateOnlyString(next) });
continue;
}
day.setHours(8 + Math.floor(rand() * 10), rand() < 0.5 ? 0 : 30);
const start = formatInstant(day);
events.push({ id, title, color, start, end: instantPlus(start, 30 * (1 + Math.floor(rand() * 4))) });
}
return events;
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/** Holds every event; the calendar only ever sees the pages it asked for. */
export class EventServer {
private db: Map<string, CalEvent>;
constructor(now: Date) {
this.db = new Map(generateEvents(now).map((e) => [e.id, e]));
}
get size() {
return this.db.size;
}
/** Events touching `[start, end)`, one simulated round-trip later. */
async fetchRange(start: Date, end: Date): Promise<CalEvent[]> {
await sleep(350);
const lo = start.getTime();
const hi = end.getTime();
return [...this.db.values()].filter(
(e) => parseInstant(e.start) < hi && parseInstant(e.end) > lo,
);
}
/** Write-through from `onEventsChange`: `after` is only the loaded pages, so a deletion is an id that went missing. */
sync(before: CalEvent[], after: CalEvent[]) {
const kept = new Set(after.map((e) => e.id));
for (const e of before) if (!kept.has(e.id)) this.db.delete(e.id);
for (const e of after) this.db.set(e.id, e);
}
}
How it works
eventsandonEventsChangemake the calendar controlled: it draws exactly the array it is given and reports every committed change as a full next array. There are no seeds, no built-in persistence and no store of its own. See Controlled state.onRangeChange(start, end)is the read signal. It fires once on mount and then on every view or date change, with the visible range as[start, end).rangePadding={7}widens what it reports by a week on each side, so the fetch for this week already covers the next one and stepping into it shows no gap.EventServer.fetchRangefilters on overlap — an event touching the range is in the page, which is how a cross-midnight event at the edge still appears.- Pages merge, they do not replace.
mergeByIdfolds a page into what is already loaded, keyed by id, so the neighbours you scrolled past stay put and a refetch of a page you edited returns the edit. Requests can complete out of order and the result is the same. - Writes go through the server copy.
onEventsChangeis only ever handed the loaded pages, so a plain overwrite would drop everything off-screen —EventServer.syncdiffs the previous and next arrays instead: an id that vanished is a delete, everything else is an upsert. - The status line is host code.
requestsrecords each range with acountthat staysnullwhile the promise is pending; the live dot reads that. Nothing in kloq knows a request happened.
Take it further
- Replace
fetchRangewith a route handler that takes?from=&to=and returnsCalEvent[]; the merge stays. Pass the two dates throughtoDateOnlyStringif the server thinks in days. - Use
onCommitas the write path instead of the diff. It hands you each change with its type, so a drag is a PATCH and a paste is one batched POST — and a rejection springs the block back. - Evict.
mergeByIdnever forgets; a long session in day view accumulates. Drop events outside the last few reported ranges and the memory stays flat.