Dynamic Loading
This guide covers how to load only the events for the currently visible range from a backend, so a calendar backed by thousands of events fetches data in slices as the user navigates instead of loading everything up front.
The functionality is available in PRO Edition only
PROOverview
Dynamic loading is a range-loading pipeline built from three parts, each with a single job:
- The store watches the visible date range. When that range changes, it fires a
request-dataaction - a notification, nothing more. The store has no built-in handler for it. - The
DynamicLoaderowns caching, preloading, debouncing, and in-flight coordination. It takes a range, decides what still needs fetching, and dispatches the results back. - A transport adapter - usually
RestDataProvider- subscribes torequest-data, hands the range to the loader, and gives the loader a way to reach your backend.
The split means you can swap any layer. Keep the REST provider but change the loader config, keep the loader but point it at a GraphQL client, or replace both with your own event-bus subscriber.
Static-data applications ignore all of this. If nothing listens for request-data, the action goes nowhere and the calendar renders the events you passed in. No store option is needed to turn dynamic loading off.
How range loading works
When the visible range changes, the store emits request-data with a half-open range [startDate, endDate):
type RequestDataAction = {
startDate: Date;
endDate: Date;
date: Date;
view: string;
};
The action fires only when the start or end timestamp actually changes. Switching to a view that computes the same range - week to a custom view covering the same seven days - does not fire a second request.
The loader answers by dispatching one or more provide-data batches:
type ProvideDataAction = {
data: { events: CalendarEvent[] };
reset?: boolean;
};
The reset flag decides how incoming events land in the store:
- Merge (
resetomitted orfalse) - incoming records replace stored records with the same ID; every other stored record stays. This is the default. - Reset (
reset: true) - clear the active event set and add only the incoming records. The store instance itself is reused, so bindings survive.
Every provide-data also clears the PRO undo/redo history, since a local snapshot can't safely span data the server owns.
One timing detail makes the wiring simple: the widget replays the initial visible range as a request-data action after the init(api) callback returns. So a provider you attach inside init receives the first request automatically - you don't fire it by hand.
Wiring with RestDataProvider
Create the loader and the provider inside init, then attach the provider with api.setNext:
import { useRef } from "react";
import {
Calendar,
Editor,
DynamicLoader,
RestDataProvider,
} from "@svar-ui/react-calendar";
function App() {
const api = useRef(null);
function init(api) {
let provider;
const loader = new DynamicLoader(
{ preload: "month", cache: 300 },
{
fetch: range => provider.getData(range),
dispatch: data => api.exec("provide-data", data),
}
);
provider = new RestDataProvider(server, { loader });
api.setNext(provider);
}
return (
<>
<Calendar ref={api} init={init} events={[]} date={new Date()} />
{api.current && <Editor api={api.current} />}
</>
);
}
export default App;
The loader takes two arguments: its config, and an infra object with fetch (turn a range into events) and dispatch (push loaded events back through the calendar bus). Referencing provider before it's assigned is safe here - the closure only runs when the first request replays, which happens after init returns and provider is set.
Pass the loader to the provider through its loader option:
new RestDataProvider(url?, {
loader?: { request(data: RequestDataAction): Promise<void> },
parseDate?: (value: any) => Date,
serializeDate?: (date: Date) => any,
});
With a loader supplied, the provider's request-data handler forwards each payload to loader.request(). Without one, that handler is a no-op. The provider's getData() does the actual fetch: getData() with no argument issues GET /events, while getData(range) issues GET /events?startDate=<encoded>&endDate=<encoded>.
Loaded start, end, and exdates values pass through parseDate (default new Date(value)); range query values pass through serializeDate (default date.toISOString()).
DynamicLoader options
The first constructor argument is either true (all defaults) or a config object. Constructing a loader makes it active - the boolean is not an enable/disable switch, just shorthand for the defaults.
type DynamicLoadingConfig =
| boolean
| {
debounce?: number;
reset?: boolean;
cache?: boolean | number;
preload?: false | "day" | "month" | "year";
};
| Option | Type | Default | Behavior |
|---|---|---|---|
debounce | number | 0 | Wait this many ms and collapse a burst of range changes to the latest one. All pending request() promises settle together. |
reset | boolean | false | Add reset: true to every dispatched provide-data, so each batch replaces the event set instead of merging. |
cache | boolean | number | true | true: keep fetched ranges forever; false: drop the cache before every request; a number: cache TTL in seconds. |
preload | false | "day" | "month" | "year" | false | Widen both range edges out to the nearest day, month, or year boundary before checking the cache. |
Preload rounds range starts down to the start of the containing day, month, or year, and rounds range ends up to the next boundary (ends already sitting on a boundary stay put). This fetches a bit more than the view strictly needs so adjacent navigation hits the cache, and it preserves the half-open range contract.
The loader uses only startDate and endDate from the request. The date and view fields are ignored once a request reaches the loader.
Custom transports
DynamicLoader doesn't care where events come from. Subscribe to request-data yourself with api.on and give the loader a fetch that talks to any backend:
function init(api: any) {
const loader = new DynamicLoader(true, {
fetch: ({ startDate, endDate }) =>
myGraphQLClient.fetchEvents(startDate, endDate),
dispatch: data => api.exec("provide-data", data),
});
api.on("request-data", data => loader.request(data));
}
If you don't want the loader's caching at all, skip it entirely: subscribe to request-data, fetch however you like, and call api.exec("provide-data", { data: { events } }) with the result. You then own caching, cancellation, and reset semantics.
REST server contract and limitations
A backend serving dynamic ranges needs to follow a few rules:
- Return a flat
CalendarEvent[]. - Treat the range as half-open and return every overlapping event -
event.start < endDate && event.end > startDate. - For recurring data, return the stored masters and exceptions, not pre-expanded occurrences. The client expands recurrence within the queried range; enable
recurringon the calendar to make it do so. - There's no pagination and no deletion or tombstone channel in this protocol.
That last point drives the main limitation: merge mode can't detect server deletions. If a record disappears on the server, an omitted response won't remove it - the record survives in the store, or reappears from an overlapping cached range. When omissions are authoritative, use reset: true, set a cache TTL, or handle deletions through your own logic.
A few more things to keep in mind:
- Cache cancellation is logical only. When a range no longer matters the loader ignores its result, but there's no
AbortControllerwired intofetch- the request still completes on the network. - In reset mode, cached partial data (or the initial empty batch) can replace what's on screen before the new fetch finishes.
- The loader deduplicates by event ID, not by range or content. When two cached ranges hold the same ID, the more recent response wins.
- Loading spinners and error handling are yours to add - wrap
fetch, or override the provider's transport.
For the full CRUD wiring and REST endpoint table, see Saving to Server.