# Loading data by date range

> Load events as visitors scroll with super-scheduler/ranges: chunked and cancellable requests, a chunk cache, loading skeletons, retries and the server contract.

Source: https://superscheduler.org/en/docs/range-loading/
Reviewed: 2026-10-07

Create one loader with `createRangeLoader` from `super-scheduler/ranges`, give it a `load({ start, end, signal })` function that returns the events of that half-open range, and attach it with `extensions={[loader]}`. The loader requests fixed day chunks around the visible range at mount and after scrolling or zooming settles, cancels requests that leave the window, caches recent chunks and merges events by id. Your server only has to answer `[start, end)` queries with stable event ids.

Range loading keeps a long timeline fast without sending the whole dataset to the browser. The loader asks your backend for the dates the visitor can see plus a margin, and forgets distant dates again. It ships with SuperScheduler Pro as `super-scheduler/ranges`; Lite displays the events you pass to it.

You do not always need it. A plan with a few thousand events loads in one request and the scheduler virtualizes it (see [Virtualization and performance](https://superscheduler.org/en/docs/performance-virtualization/)). Reach for range loading when the timeline spans years, when the full dataset is too large to fetch or keep in memory, or when your API is already paginated by date.

## How the loader works
The loader divides the time axis into chunks of `chunkDays` days and keeps a window of chunks around the viewport:

- **Fixed chunk boundaries.** Boundaries are multiples of `chunkDays` counted from 1970-01-01, so they do not depend on `startDate`, on the scroll position or on `weekStarts`. Seven-day chunks start on Thursdays; with `chunkDays: 14` and `startDate="2026-01-01"`, the first visible chunk is `[2026-01-01, 2026-01-15)`. The same chunk always produces the same request, so responses can be cached.
- **Requests at boundaries, never per frame.** The wanted window is every chunk that overlaps the visible dates, plus `prefetch` chunks on each side (a prefetch chunk may lie before `startDate`). Missing chunks are requested right after mount and again when a scroll or zoom gesture settles. Programmatic scrolling with `control.scrollTo()` counts as a scroll.
- **Cancellation.** A request whose chunk leaves the wanted window before it answers is aborted through its `AbortSignal`. If your function ignores the signal, its late result is discarded anyway.
- **Cache and eviction.** At most `cacheChunks` chunks are kept. Beyond that, the chunks farthest from the view are evicted and their events leave the scheduler, unless another kept chunk returned them too. Scrolling back requests them again.
- **Merge by id.** An event returned by two chunks, because it crosses a boundary, appears once.
- **Feedback.** While a chunk loads, a translucent band covers its dates. It has the class `super-scheduler__range-skeleton` and uses the `--super-scheduler-skeleton-base` token; `skeleton: false` removes it.
- **No history.** Loads never create undo entries, and they reach `onEventsChange` with `reason: 'load'`.

> **Behavior:**
> The loader slices time, not rows. Every request covers all resources for its dates, so your endpoint returns the events of every row in that range. Row headers come from the `resources` you pass, as usual.

## Attach a loader
Create the loader once per scheduler and pass it in `extensions`. The control attaches and disposes extensions by object identity, so a loader created during every render would restart its cache each time. The `load` function receives the chunk's `start` and `end` as `SuperScheduler.Date` values; `start.value` is the civil ISO string (`2026-01-01T00:00:00`) to send to your API.

```tsx
// src/RangePlanning.tsx
import { useMemo, useState, type RefObject } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { createRangeLoader } from 'super-scheduler/ranges'
import 'super-scheduler/styles.css'
import { createSaveHandler } from './save-changes'

const ROOMS: SuperScheduler.ResourceData[] = [
  { id: 'r101', name: 'Room 101' },
  { id: 'r102', name: 'Room 102' },
  { id: 'r103', name: 'Room 103' },
]
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Month' },
  { groupBy: 'Day', format: 'd' },
]

/** One server row as a scheduler event. The server sends civil ISO strings with seconds. */
export function toEvent(booking: Booking): SuperScheduler.EventData {
  return {
    id: booking.id,
    resource: booking.roomId,
    start: booking.start,
    end: booking.end,
    text: booking.guest,
  }
}

/**
 * The loader is created outside render and receives the control's ref object. Its callbacks read
 * `controlRef.current` later, when a chunk fails, never while React renders.
 */
function createBookingLoader(controlRef: RefObject<SuperScheduler.Scheduler | null>) {
  return createRangeLoader({
    // One chunk, [start, end). The signal aborts requests the visitor scrolled away from.
    load: async ({ start, end, signal }) => {
      const bookings = await fetchBookings(start.value, end.value, signal)
      return bookings.map(toEvent)
    },
    chunkDays: 14,
    prefetch: 1,
    onError: (error, range) => {
      console.error(error)
      const from = range.start.toString('d MMM')
      const to = range.end.addDays(-1).toString('d MMM')
      controlRef.current?.message(
        `Could not load ${from} to ${to}. Scroll back or refresh to retry.`,
      )
    },
  })
}

export function RangePlanning() {
  const { controlRef } = useSchedulerControl()

  // Created once per scheduler: extensions are attached and disposed by object identity.
  const [loader] = useState(() => createBookingLoader(controlRef))
  const extensions = useMemo(() => [loader], [loader])
  const onEventsChange = useMemo(() => createSaveHandler(loader), [loader])

  return (
    <SuperSchedulerComponent
      controlRef={controlRef}
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      heightSpec="Fixed"
      height={480}
      timeHeaders={TIME_HEADERS}
      resources={ROOMS}
      // No `events` prop: the loader writes into the control's own store (uncontrolled).
      extensions={extensions}
      onEventsChange={onEventsChange}
    />
  )
}
```
This scheduler has no `events` prop, so the loader writes directly into the control's own store. Edits made by the user still arrive in `onEventsChange`; the handler below persists moves and resizes, skips loads, and asks the server again when a save is rejected:

```ts
// src/save-changes.ts
import { SuperScheduler } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import type { RangeLoader } from 'super-scheduler/ranges'

const ticks = (value: SuperScheduler.DateInput) => new SuperScheduler.Date(value).ticks

/** The range that covers an event before and after a change. */
function span(before: SuperScheduler.EventData, after: SuperScheduler.EventData) {
  return {
    start: ticks(before.start) < ticks(after.start) ? before.start : after.start,
    end: ticks(before.end) > ticks(after.end) ? before.end : after.end,
  }
}

/**
 * Persists moves and resizes. Range loads arrive with reason 'load' and are skipped. When the server
 * rejects a change, reloading the old and new dates puts the event back where the server has it.
 */
export function createSaveHandler(loader: RangeLoader) {
  return ({ reason, changed, removed }: SchedulerEventsChangeArgs) => {
    if (reason !== 'move' && reason !== 'resize') return
    for (const after of changed) {
      const before = removed.find((item) => item.id === after.id)
      if (before === undefined || after.resource === undefined) continue
      saveBooking({
        id: String(after.id),
        resource: String(after.resource),
        // After a drag, start and end are SuperScheduler.Date objects; String() gives civil ISO.
        start: String(after.start),
        end: String(after.end),
      }).catch(() => loader.reload(span(before, after)))
    }
  }
}
```
You should see a skeleton band over the visible dates for a moment, then the bookings. In the network panel, scrolling a few weeks to the right produces one request per new 14-day chunk once the scroll stops, and scrolling quickly across many chunks produces only the requests for where you stop.

## Options
| Option | Type | Default | Meaning |
|---|---|---|---|
| `load` | `({ start, end, signal }) => Promise<EventData[]>` | required | Returns the events that overlap `[start, end)`. |
| `chunkDays` | `number` | `7` | Days per chunk. Larger chunks mean fewer, bigger requests. |
| `prefetch` | `number` | `1` | Chunks loaded on each side of the visible range. |
| `cacheChunks` | `number` | `26` | Chunks kept before the farthest ones are evicted. |
| `skeleton` | `boolean` | `true` | Shows the loading band over pending chunks. |
| `onError` | `(error, { start, end }) => void` | none | Called once per failed chunk. Aborted requests are not errors. |

The loader object itself exposes `reload(range?)`, `clear()` and a `loading` getter that is `true` while any chunk is pending. `loading` is a plain property, not a subscription: read it when you need it, or drive your own spinner from `load`.

## Controlled events or the control's store
Range loading works with both data modes described in [Controlled state](https://superscheduler.org/en/docs/controlled-state/):

- **No `events` prop, or `defaultEvents`.** The loader adds new events to the control's store, updates them when a chunk is reloaded and removes them when their chunk is evicted. Events already in the store are not overwritten by later loads, so a booking the user just moved stays where it is until you call `reload()`. This is the simplest choice when users edit range-loaded data.
- **Controlled `events` plus `onEventsChange`.** The loader never writes to the store. Each finished chunk calls `onEventsChange` with `reason: 'load'` and `events` set to the merged list, and your state must adopt it like any other change. React batches these updates; expect one call per chunk.

```tsx
// src/ControlledRange.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SuperScheduler } from 'super-scheduler'
import { createRangeLoader } from 'super-scheduler/ranges'
import { toEvent } from './RangePlanning'

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
}

export function ControlledRange({ rooms }: Props) {
  // React state owns the events; the loader proposes the merged list through onEventsChange.
  const [events, setEvents] = useState<SuperScheduler.EventData[]>([])
  const owned = useMemo(() => events.slice(), [events])

  const [loader] = useState(() =>
    createRangeLoader({
      load: async ({ start, end, signal }) =>
        (await fetchBookings(start.value, end.value, signal)).map(toEvent),
    }),
  )
  const extensions = useMemo(() => [loader], [loader])

  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => {
      // Adopt every change, loads included: `events` is the full list after this change.
      setEvents([...args.events])
      if (args.reason !== 'move' && args.reason !== 'resize') return
      for (const after of args.changed) {
        const before = args.removed.find((item) => item.id === after.id)
        if (before === undefined || after.resource === undefined) continue
        const change = {
          id: String(after.id),
          resource: String(after.resource),
          start: String(after.start),
          end: String(after.end),
        }
        saveBooking(change).then(
          () => {
            // The cached chunks still hold the version loaded before the change: refetch them.
            loader.clear()
            void loader.reload()
          },
          // Rejected: put the previous version back in state.
          () =>
            setEvents((current) => current.map((item) => (item.id === after.id ? before : item))),
        )
      }
    },
    [loader],
  )

  return (
    <SuperSchedulerComponent
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      resources={rooms}
      events={owned}
      onEventsChange={onEventsChange}
      extensions={extensions}
    />
  )
}
```
> **Limitation:**
> With controlled events, every chunk that finishes publishes the merged list again, including the version of each event that its chunk returned earlier. An event replaced by a drag or resize can therefore jump back to its loaded position when another chunk arrives, until the cache is refreshed. Call `loader.clear()` and `loader.reload()` after a successful save, as above, or use the control's own store, where loads never overwrite existing events.

## Navigation, refresh and filters
Two methods cover most toolbar actions:

- `reload()` requests the visible window again, cached or not, and replaces those chunks. Events missing from the new responses are removed. `reload({ start, end })` does the same for any range, which is useful after a save or a server notification.
- `clear()` cancels pending requests and empties the cache. It does not remove the events on screen.

Changing `startDate` or `days` through props or `control.update()` does not scroll by itself, so it does not trigger a load. Scroll to the new period and call `reload()` once React has applied the change. When the query itself changes, for example another site or a status filter, clear the cache, remove the old events and load again:

```tsx
// src/SitePlanning.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import { createRangeLoader } from 'super-scheduler/ranges'
import { toEvent } from './RangePlanning'

type SiteId = 'north' | 'south'

const ROOMS: Record<SiteId, SuperScheduler.ResourceData[]> = {
  north: [
    { id: 'n1', name: 'North 1' },
    { id: 'n2', name: 'North 2' },
  ],
  south: [
    { id: 's1', name: 'South 1' },
    { id: 's2', name: 'South 2' },
  ],
}

/** The loader is created once; each chunk request reads the site chosen at that moment. */
function createSiteLoader(initial: SiteId) {
  let site = initial
  return {
    loader: createRangeLoader({
      load: async ({ start, end, signal }) =>
        (await fetchSiteBookings(site, start.value, end.value, signal)).map(toEvent),
    }),
    /** Later requests query this site. */
    setSite: (next: SiteId) => {
      site = next
    },
  }
}

export function SitePlanning() {
  const { controlRef } = useSchedulerControl()
  const [month, setMonth] = useState(() => SuperScheduler.Date.today().firstDayOfMonth())
  const [site, setSite] = useState<SiteId>('north')

  const [{ loader, setSite: setLoaderSite }] = useState(() => createSiteLoader('north'))
  const extensions = useMemo(() => [loader], [loader])

  // A new period is not a scroll: after React applied it, show its start and load the view.
  const shown = useRef(month)
  useEffect(() => {
    if (shown.current.equals(month)) return
    shown.current = month
    controlRef.current?.scrollTo(month)
    void loader.reload()
  }, [controlRef, loader, month])

  // Another site: forget its cache, drop its events and load the visible range again.
  const changeSite = (next: SiteId) => {
    setLoaderSite(next)
    setSite(next)
    loader.clear()
    controlRef.current?.update({ events: [] })
    void loader.reload()
  }

  return (
    <>
      <div role="toolbar" aria-label="Planning">
        <button type="button" onClick={() => setMonth((m) => m.addMonths(-1))}>
          Previous month
        </button>
        <button type="button" onClick={() => setMonth((m) => m.addMonths(1))}>
          Next month
        </button>
        <button type="button" onClick={() => void loader.reload()}>
          Refresh
        </button>
        <select value={site} onChange={(event) => changeSite(event.target.value as SiteId)}>
          <option value="north">North</option>
          <option value="south">South</option>
        </select>
      </div>
      <SuperSchedulerComponent
        controlRef={controlRef}
        startDate={month}
        days={month.daysInMonth()}
        scale="Day"
        cellWidth={48}
        resources={ROOMS[site]}
        extensions={extensions}
      />
    </>
  )
}
```
With controlled events, do the same with `setEvents([])` instead of `control.update({ events: [] })`, and call `reload()` from an effect that runs after the empty list was applied. If resetting the scroll position is acceptable, rendering the scheduler with `key={site}` gives you a fresh control and a fresh loader.

## Errors and retries
When `load` rejects, the loader calls `onError(error, { start, end })` for that chunk, removes its skeleton and forgets it. The chunk is requested again on the next settled scroll or zoom that still wants it, or on `reload()`. Show the failure where users look: `control.message()` displays a short message bar inside the scheduler, as in the first example. Requests aborted by the loader never reach `onError`.

## The server contract
Your endpoint answers one question: which events overlap this half-open civil range?

```txt
GET /api/bookings?start=2026-01-01T00:00:00&end=2026-01-15T00:00:00
```

```json
[
  { "id": "b-1042", "roomId": "r101", "guest": "Ana Ruiz", "start": "2025-12-30T14:00:00", "end": "2026-01-03T11:00:00" },
  { "id": "b-1043", "roomId": "r102", "guest": "Tom Berg", "start": "2026-01-14T14:00:00", "end": "2026-01-16T11:00:00" }
]
```

- **Half-open overlap.** Return every event with `event.start < end` and `event.end > start`. An event that starts before the chunk or ends after it belongs to the response, like `b-1042` and `b-1043` above. An event that ends exactly at `start` does not.
- **Stable, unique ids.** The same booking must have the same `id` in every response and no two events may share one, across all resources. Ids are compared strictly: `1` and `'1'` are different events.
- **Civil date-times with seconds.** `start` and `end` are wall-clock values without a zone, and strings need seconds (`2026-01-14T14:00:00`). If you store instants, convert them in the business time zone first; see [Languages, civil dates and time zones](https://superscheduler.org/en/docs/locales-dates-timezones/#time-zones).
- **Cancellation is welcome.** Passing the `signal` to `fetch` frees the connection early; the loader does not depend on it.
- **Cacheable.** Fixed chunk boundaries make identical requests repeat, so HTTP caching or a CDN can serve them. Keep the cache short if other users edit the same plan.

## Lower level: dynamicLoading and onScroll
The control also has the classic callback-based mechanism. With `dynamicLoading` and an `onScroll` handler, the control calls `onScroll` once scrolling has been quiet for `scrollDelayDynamic` milliseconds (500 by default). `args.viewport` holds the visible `start`, `end` and `resources`; you fill `args.events` and call `args.loaded()`.

```tsx
// src/DynamicPlanning.tsx
import { useCallback, useRef, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerScrollArgs, SuperScheduler } from 'super-scheduler'
import { toEvent } from './RangePlanning'

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  /** Events of the first screen: onScroll is not called at mount. */
  readonly initial: SuperScheduler.EventData[]
}

export function DynamicPlanning({ rooms, initial }: Props) {
  const [firstScreen] = useState(() => initial.slice())
  const pending = useRef<AbortController | null>(null)

  // Called once scrolling has been quiet for `scrollDelayDynamic` ms.
  const onScroll = useCallback((args: SchedulerScrollArgs) => {
    pending.current?.abort()
    const request = new AbortController()
    pending.current = request
    // Load a margin around the viewport: by default the result replaces every event.
    const from = args.viewport.start.addDays(-14)
    const to = args.viewport.end.addDays(14)
    args.async = true
    fetchBookings(from.value, to.value, request.signal).then(
      (bookings) => {
        args.events = bookings.map(toEvent)
        args.loaded()
      },
      () => {
        // Failed or superseded: keep what is on screen.
        args.clearEvents = false
        args.loaded()
      },
    )
  }, [])

  return (
    <SuperSchedulerComponent
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      resources={rooms}
      defaultEvents={firstScreen}
      dynamicLoading
      scrollDelayDynamic={300}
      onScroll={onScroll}
    />
  )
}
```
Compared with the range loader, this gives you full control and nothing else: no chunk alignment, cache, prefetch, skeleton or cancellation. `onScroll` is not called at mount, so render the first screen yourself. `args.async` starts as `true`, so the result is applied only when you call `args.loaded()`. By default `args.clearEvents` is `true` and the returned events replace all events; set it to `false` to merge by id and list ids to drop in `args.remove`. Because `viewport.resources` lists the visible rows, this mechanism can also load per row.

## What stays in your application
- Persisting changes. The loader reads; your `onEventsChange` handler or explicit actions write.
- Live updates from other users. Automatic refresh options (`autoRefreshEnabled` and related) are reserved and do nothing. Poll, or apply server notifications with `control.events.add/update/remove`, or call `reload({ start, end })` for the affected dates.
- Links and rows. The loader handles events only; pass `links` and `resources` yourself.
- HTTP loading built into the control (`events.load(url)`, `rows.load(url)`, `links.load(url)`) is typed but reserved: it warns in development and loads nothing.

Related guides: [Controlled state](https://superscheduler.org/en/docs/controlled-state/), [Undo and redo](https://superscheduler.org/en/docs/undo-redo/), [Resources, events and intervals](https://superscheduler.org/en/docs/resources-events-intervals/) and the [API reference](https://superscheduler.org/en/docs/api-reference/#ranges).
