# Chargement des données par plage de dates

> Chargez les événements au fil du défilement avec super-scheduler/ranges : requêtes par tranches annulables, cache, squelettes, reprises et contrat serveur.

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

Créez un chargeur avec `createRangeLoader` depuis `super-scheduler/ranges`, donnez-lui une fonction `load({ start, end, signal })` qui renvoie les événements de cette plage semi-ouverte, et attachez-le avec `extensions={[loader]}`. Le chargeur demande des tranches fixes de jours autour de la plage visible au montage, puis une fois le défilement ou le zoom stabilisé ; il annule les requêtes qui sortent de la fenêtre, met en cache les tranches récentes et fusionne les événements par id. Votre serveur n’a qu’à répondre à des requêtes `[start, end)` avec des id d’événements stables.

Le chargement par plages préserve la rapidité d’une longue frise sans envoyer tout le jeu de données au navigateur. Le chargeur demande à votre backend les dates que le visiteur peut voir, plus une marge, puis oublie les dates qui s’éloignent. Il est livré avec SuperScheduler Pro sous le nom `super-scheduler/ranges` ; Lite affiche les événements que vous lui passez.

Vous n’en avez pas toujours besoin. Un planning de quelques milliers d’événements se charge en une requête et le planificateur le virtualise (voir [Virtualisation et performances](https://superscheduler.org/fr/docs/performance-virtualization/)). Tournez-vous vers le chargement par plages quand la frise couvre plusieurs années, quand le jeu de données complet est trop volumineux pour être récupéré ou gardé en mémoire, ou quand votre API est déjà paginée par date.

## Fonctionnement du chargeur
Le chargeur découpe l’axe du temps en tranches de `chunkDays` jours et garde une fenêtre de tranches autour de la zone visible :

- **Limites de tranches fixes.** Les limites sont des multiples de `chunkDays` comptés depuis le 1970-01-01 ; elles ne dépendent donc ni de `startDate`, ni de la position de défilement, ni de `weekStarts`. Les tranches de sept jours commencent le jeudi ; avec `chunkDays: 14` et `startDate="2026-01-01"`, la première tranche visible est `[2026-01-01, 2026-01-15)`. Une même tranche produit toujours la même requête, si bien que les réponses peuvent être mises en cache.
- **Des requêtes aux limites, jamais à chaque image.** La fenêtre voulue comprend chaque tranche qui chevauche les dates visibles, plus `prefetch` tranches de chaque côté (une tranche préchargée peut se trouver avant `startDate`). Les tranches manquantes sont demandées juste après le montage, puis de nouveau quand un geste de défilement ou de zoom se stabilise. Un défilement programmatique avec `control.scrollTo()` compte comme un défilement.
- **Annulation.** Une requête dont la tranche quitte la fenêtre voulue avant de répondre est interrompue via son `AbortSignal`. Si votre fonction ignore le signal, son résultat tardif est de toute façon écarté.
- **Cache et éviction.** Au plus `cacheChunks` tranches sont conservées. Au-delà, les tranches les plus éloignées de la vue sont évincées et leurs événements quittent le planificateur, sauf si une autre tranche conservée les a aussi renvoyés. Revenir à ces dates relance leur chargement.
- **Fusion par id.** Un événement renvoyé par deux tranches, parce qu’il chevauche une limite, n’apparaît qu’une fois.
- **Retour visuel.** Pendant le chargement d’une tranche, une bande translucide couvre ses dates. Elle porte la classe `super-scheduler__range-skeleton` et utilise le token `--super-scheduler-skeleton-base` ; `skeleton: false` la supprime.
- **Pas d’historique.** Les chargements ne créent jamais d’entrées d’annulation, et ils parviennent à `onEventsChange` avec `reason: 'load'`.

> **Behavior:**
> Le chargeur découpe le temps, pas les lignes. Chaque requête couvre toutes les ressources pour ses dates : votre endpoint renvoie donc les événements de chaque ligne sur cette plage. Les en-têtes de ligne proviennent, comme d’habitude, des `resources` que vous passez.

## Attacher un chargeur
Créez le chargeur une seule fois par planificateur et passez-le dans `extensions`. Le contrôle attache et libère les extensions selon l’identité des objets : un chargeur recréé à chaque rendu repartirait chaque fois d’un cache vide. La fonction `load` reçoit le `start` et le `end` de la tranche sous forme de valeurs `SuperScheduler.Date` ; `start.value` est la chaîne ISO civile (`2026-01-01T00:00:00`) à envoyer à votre 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}
    />
  )
}
```
Ce planificateur n’a pas de prop `events` : le chargeur écrit donc directement dans le store propre au contrôle. Les modifications faites par l’utilisateur arrivent toujours dans `onEventsChange` ; le handler ci-dessous enregistre les déplacements et redimensionnements, ignore les chargements et interroge de nouveau le serveur quand un enregistrement est rejeté :

```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)))
    }
  }
}
```
Vous devriez voir un instant une bande de chargement sur les dates visibles, puis les réservations. Dans l’onglet Réseau, faire défiler de quelques semaines vers la droite produit une requête par nouvelle tranche de 14 jours une fois le défilement arrêté, et faire défiler rapidement sur de nombreuses tranches ne produit que les requêtes de l’endroit où vous vous arrêtez.

## Options
| Option | Type | Défaut | Signification |
|---|---|---|---|
| `load` | `({ start, end, signal }) => Promise<EventData[]>` | obligatoire | Renvoie les événements qui chevauchent `[start, end)`. |
| `chunkDays` | `number` | `7` | Jours par tranche. Des tranches plus grandes donnent des requêtes moins nombreuses mais plus lourdes. |
| `prefetch` | `number` | `1` | Tranches chargées de chaque côté de la plage visible. |
| `cacheChunks` | `number` | `26` | Tranches conservées avant l’éviction des plus éloignées. |
| `skeleton` | `boolean` | `true` | Affiche la bande de chargement sur les tranches en attente. |
| `onError` | `(error, { start, end }) => void` | aucun | Appelé une fois par tranche en échec. Les requêtes interrompues ne sont pas des erreurs. |

L’objet chargeur expose lui-même `reload(range?)`, `clear()` et un getter `loading` qui vaut `true` tant qu’une tranche est en attente. `loading` est une simple propriété, pas un abonnement : lisez-la quand vous en avez besoin, ou pilotez votre propre indicateur depuis `load`.

## Événements contrôlés ou store du contrôle
Le chargement par plages fonctionne avec les deux modes de données décrits dans [État contrôlé](https://superscheduler.org/fr/docs/controlled-state/) :

- **Sans prop `events`, ou avec `defaultEvents`.** Le chargeur ajoute les nouveaux événements au store du contrôle, les met à jour quand une tranche est rechargée et les retire quand leur tranche est évincée. Les événements déjà présents dans le store ne sont pas écrasés par les chargements suivants : une réservation que l’utilisateur vient de déplacer reste donc où elle est jusqu’à ce que vous appeliez `reload()`. C’est le choix le plus simple quand les utilisateurs modifient des données chargées par plages.
- **`events` contrôlé plus `onEventsChange`.** Le chargeur n’écrit jamais dans le store. Chaque tranche terminée appelle `onEventsChange` avec `reason: 'load'` et `events` contenant la liste fusionnée, et votre état doit l’adopter comme n’importe quelle autre modification. React regroupe ces mises à jour ; comptez un appel par tranche.

```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:**
> Avec des événements contrôlés, chaque tranche terminée publie à nouveau la liste fusionnée, y compris la version de chaque événement que sa tranche a renvoyée plus tôt. Un événement modifié par un glisser ou un redimensionnement peut donc revenir à sa position chargée quand une autre tranche arrive, jusqu’au rafraîchissement du cache. Appelez `loader.clear()` et `loader.reload()` après un enregistrement réussi, comme ci-dessus, ou utilisez le store propre au contrôle, où les chargements n’écrasent jamais les événements existants.

## Navigation, rafraîchissement et filtres
Deux méthodes couvrent la plupart des actions d’une barre d’outils :

- `reload()` redemande la fenêtre visible, en cache ou non, et remplace ces tranches. Les événements absents des nouvelles réponses sont retirés. `reload({ start, end })` fait de même pour n’importe quelle plage, ce qui est utile après un enregistrement ou une notification du serveur.
- `clear()` annule les requêtes en attente et vide le cache. Il ne retire pas les événements affichés.

Modifier `startDate` ou `days` via les props ou `control.update()` ne provoque aucun défilement, et ne déclenche donc aucun chargement. Faites défiler jusqu’à la nouvelle période et appelez `reload()` une fois que React a appliqué la modification. Quand la requête elle-même change, par exemple un autre site ou un filtre de statut, videz le cache, retirez les anciens événements et rechargez :

```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}
      />
    </>
  )
}
```
Avec des événements contrôlés, faites de même avec `setEvents([])` au lieu de `control.update({ events: [] })`, et appelez `reload()` depuis un effet qui s’exécute après l’application de la liste vide. Si réinitialiser la position de défilement est acceptable, rendre le planificateur avec `key={site}` vous donne un contrôle neuf et un chargeur neuf.

## Erreurs et nouvelles tentatives
Quand la promesse de `load` est rejetée, le chargeur appelle `onError(error, { start, end })` pour cette tranche, retire sa bande de chargement et l’oublie. La tranche est redemandée au prochain défilement ou zoom stabilisé qui en a encore besoin, ou lors d’un `reload()`. Affichez l’échec là où les utilisateurs regardent : `control.message()` affiche une courte barre de message dans le planificateur, comme dans le premier exemple. Les requêtes interrompues par le chargeur n’atteignent jamais `onError`.

## Le contrat serveur
Votre endpoint répond à une seule question : quels événements chevauchent cette plage civile semi-ouverte ?

```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" }
]
```

- **Chevauchement semi-ouvert.** Renvoyez chaque événement tel que `event.start < end` et `event.end > start`. Un événement qui commence avant la tranche ou se termine après fait partie de la réponse, comme `b-1042` et `b-1043` ci-dessus. Un événement qui se termine exactement à `start` n’en fait pas partie.
- **Des id stables et uniques.** Une même réservation doit avoir le même `id` dans chaque réponse, et deux événements ne peuvent pas partager un id, toutes ressources confondues. Les id sont comparés strictement : `1` et `'1'` sont deux événements différents.
- **Des dates et heures civiles avec secondes.** `start` et `end` sont des valeurs d’horloge murale sans fuseau, et les chaînes doivent comporter les secondes (`2026-01-14T14:00:00`). Si vous stockez des instants, convertissez-les d’abord dans le fuseau horaire de l’activité ; voir [Langues, dates civiles et fuseaux horaires](https://superscheduler.org/fr/docs/locales-dates-timezones/#time-zones).
- **L’annulation est bienvenue.** Passer le `signal` à `fetch` libère la connexion plus tôt ; le chargeur n’en dépend pas.
- **Mise en cache possible.** Les limites de tranches fixes font se répéter des requêtes identiques, que le cache HTTP ou un CDN peut servir. Gardez un cache court si d’autres utilisateurs modifient le même planning.

## Plus bas niveau : dynamicLoading et onScroll
Le contrôle dispose aussi du mécanisme classique fondé sur des callbacks. Avec `dynamicLoading` et un handler `onScroll`, le contrôle appelle `onScroll` une fois que le défilement est resté calme pendant `scrollDelayDynamic` millisecondes (500 par défaut). `args.viewport` contient les `start`, `end` et `resources` visibles ; vous remplissez `args.events` et appelez `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}
    />
  )
}
```
Par rapport au chargeur de plages, ce mécanisme vous donne un contrôle total et rien d’autre : ni alignement des tranches, ni cache, ni préchargement, ni bande de chargement, ni annulation. `onScroll` n’est pas appelé au montage : affichez donc le premier écran vous-même. `args.async` vaut `true` au départ, si bien que le résultat n’est appliqué que lorsque vous appelez `args.loaded()`. Par défaut, `args.clearEvents` vaut `true` et les événements renvoyés remplacent tous les événements ; passez-le à `false` pour fusionner par id, et listez dans `args.remove` les id à retirer. Comme `viewport.resources` liste les lignes visibles, ce mécanisme permet aussi de charger ligne par ligne.

## Ce qui reste dans votre application
- L’enregistrement des modifications. Le chargeur lit ; votre handler `onEventsChange` ou vos actions explicites écrivent.
- Les mises à jour en direct venant d’autres utilisateurs. Les options de rafraîchissement automatique (`autoRefreshEnabled` et apparentées) sont réservées et ne font rien. Interrogez périodiquement le serveur, appliquez ses notifications avec `control.events.add/update/remove`, ou appelez `reload({ start, end })` pour les dates concernées.
- Les liens et les lignes. Le chargeur ne gère que les événements ; passez `links` et `resources` vous-même.
- Le chargement HTTP intégré au contrôle (`events.load(url)`, `rows.load(url)`, `links.load(url)`) est typé mais réservé : il émet un avertissement en développement et ne charge rien.

Guides associés : [État contrôlé](https://superscheduler.org/fr/docs/controlled-state/), [Annuler et rétablir](https://superscheduler.org/fr/docs/undo-redo/), [Ressources, événements et intervalles](https://superscheduler.org/fr/docs/resources-events-intervals/) et la [référence de l’API](https://superscheduler.org/fr/docs/api-reference/#ranges).
