Skip to content
SuperScheduler

Pro modulesApplies toSuperScheduler Pro

Loading data by date range

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.

Verified against v0.1.0 · reviewed October 7, 2026.md

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). 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'.

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.

src/RangePlanning.tsxtsx
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:

src/save-changes.tsts
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

OptionTypeDefaultMeaning
load({ start, end, signal }) => Promise<EventData[]>requiredReturns the events that overlap [start, end).
chunkDaysnumber7Days per chunk. Larger chunks mean fewer, bigger requests.
prefetchnumber1Chunks loaded on each side of the visible range.
cacheChunksnumber26Chunks kept before the farthest ones are evicted.
skeletonbooleantrueShows the loading band over pending chunks.
onError(error, { start, end }) => voidnoneCalled 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:

  • 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.
src/ControlledRange.tsxtsx
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}
    />
  )
}

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:

src/SitePlanning.tsxtsx
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?

txttxt
GET /api/bookings?start=2026-01-01T00:00:00&end=2026-01-15T00:00:00
jsonjson
[
  { "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.
  • 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().

src/DynamicPlanning.tsxtsx
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, Undo and redo, Resources, events and intervals and the API reference.