Loading on Demand
Pass a function instead of an array and the scheduler asks it only for the dates on screen. What it has been given once it keeps; what it no longer needs it cancels.
A Function Instead of an Array
source receives a range and returns the events that overlap it, as an array or a promise of one. While
it is set, events is ignored. The demo below calls a real endpoint of this site with a chosen delay; the network
tab of your browser shows the same requests the log does.
Oct 19 – 25, 2026
Network
- Navigate to load a range.
<script lang="ts">
import { Scheduler, type EventSourceFn } from '@sv5ui/scheduler';
// Called for each stretch of dates the scheduler does not hold yet.
// Return every event that overlaps the range, as EventInput.
const source: EventSourceFn = async ({ range, timeZone, signal }) => {
const params = new URLSearchParams({
from: range.start.toAbsoluteString(),
to: range.end.toAbsoluteString()
});
const response = await fetch(`/api/events?${params}`, { signal });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.json();
};
</script>
<!-- events is ignored while source is set -->
<Scheduler {source} onLoadError={(error) => toast.error(String(error))} timeZone="Asia/Ho_Chi_Minh" />What Gets Requested
The scheduler remembers which dates it holds. When the visible range changes it asks only
for the stretches it has not seen, one call each, so range in a call can be narrower than the screen. Step forward a week and back: the second visit makes
no request. Switch from a loaded week to the month around it and two requests go out, one for
each side. Return every event that overlaps the range; the scheduler drops what falls outside.
// What one call receives
interface LoadContext {
range: DateRange; // a stretch not loaded yet; may be smaller than the screen
timeZone: TimeZoneId; // the scheduler's zone, for a server that wants it
signal: AbortSignal; // aborted when the range changes before this load settles
}
// A week is loaded. The reader switches to the month view: two calls go out,
// one for the days before the week and one for the days after it. The week
// itself is not asked for again.
// Return everything that overlaps the range, in any of the date forms the
// scheduler reads. Events entirely outside are dropped.-- "Overlaps [from, to)": starts before the range ends and ends after it starts.
-- Both ends exclusive on the right, which is how the scheduler thinks too.
SELECT * FROM events
WHERE starts_at < :to
AND ends_at > :from
ORDER BY starts_at;Cancelling Stale Requests
Every call gets an AbortSignal that fires when the range moves on before the call settles. Hand it to fetch and the browser drops the connection; the answer, if it still arrives, is ignored, and an abort
is never reported as an error. Set a long delay above and press Next three times to see two requests
aborted.
const source: EventSourceFn = async ({ range, signal }) => {
// Pass the signal on. A reader who clicks Next three times quickly makes three
// loads; the first two are aborted and their fetches cancelled, and an abort
// never reaches onLoadError.
const response = await fetch(url(range), { signal });
return response.json();
};While It Loads
The calendar stays usable. A request that takes longer than 150 milliseconds shows a thin progress bar along the top of the view; a range already held never shows one. The empty state of the week, day and agenda views waits until the load settles, so a range is not called empty before it has answered.
Errors
A throw or rejection reaches onLoadError. The range is not marked as loaded, so the next visit asks again. Flip "Fail the next
request" above and step to a new week.
<Scheduler
{source}
onLoadError={(error) => {
// The range that failed is not marked as loaded, so coming back to it asks again.
toast.error('Could not load events', { description: String(error) });
}}
/>Reloading
There is no method to call. The loader belongs to the function you passed: pass a new
function and the old cache is gone and the visible range is fetched again. In Svelte that is
a $derived that reads a counter. A function written inline in the markup is created once and stays, so it
does not reload by accident when the parent re-renders.
<script lang="ts">
let version = $state(0);
// The loader belongs to the source function. A new function is a new loader
// with an empty cache, so the visible range is fetched again. The derived
// reads version and returns a fresh function; the read has to be outside
// the async body, since the body runs later, when the scheduler calls it.
const source = $derived.by<EventSourceFn>(() => {
void version;
return async ({ range, signal }) => load(range, signal);
});
</script>
<Scheduler {source} />
<Button label="Reload" onclick={() => (version += 1)} />Server-Side Filters
The same mechanism carries filters the server should apply. Keep them in the closure and let
the derived source change with them; every change is a fresh load. hiddenCalendars, search and filter still work on what was loaded; see Calendars & Filtering.
<script lang="ts">
let team = $state('all');
// Filters the server applies travel the same way: read them in the derived
// so a change makes a new source, which fetches again.
const source = $derived.by<EventSourceFn>(() => {
const selected = team;
return async ({ range, signal }) => {
const params = new URLSearchParams({ from: iso(range.start), to: iso(range.end), team: selected });
return (await fetch(`/api/events?${params}`, { signal })).json();
};
});
</script>
<Select items={teams} bind:value={team} />
<Scheduler {source} />Saving With a Source
Drag, resize, create and delete work exactly as with an array, and onMutate runs for each. A confirmed change is also written into the loaded cache, so leaving a range and
coming back shows the event where it was put, without a refetch. The demo above logs each mutation
beside the requests.
<!-- Saving works the same with a source. A confirmed change also updates the
cache, so going back to a range shows the moved event without a refetch. -->
<Scheduler
{source}
onMutate={async (mutation) => {
await api.save(mutation);
}}
/>Time Zones and Caching
Changing timeZone does not fetch again. The loaded events are kept as they arrived and read in the new zone: a string
with an offset stays the same instant, a naive string keeps its clock time. A series that comes
back from a source is kept for every range, since its occurrences may fall anywhere.
Array or Source
| bind:events | source | |
|---|---|---|
Two-way binding | Yes: UI changes are written back into the array | No: the scheduler owns what it loaded |
Amount of data | Everything, up front | Only the ranges the reader visits |
Changing data from outside | Edit the array | Pass a new source function |
Saving | onMutate | onMutate, and the cache is updated too |
Loading state | None | A thin progress bar after 150ms; empty states wait |
Fits | Local data, small teams, offline | A server, many events, many users |
Props
The props a loading calendar uses.
| Prop | Type | Default |
|---|---|---|
source | EventSourceFn<T> | - |
onLoadError | (error: unknown) => void | - |
filter | (event: SchedulerEvent<T>) => boolean | - |
timeZone | TimeZoneId | local zone |
onMutate | (m: Mutation<T>) => MutationResult<T> | Promise<...> | - |