Modules ProS’applique àSuperScheduler Pro
Chargement des données par plage de dates
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). 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
chunkDayscomptés depuis le 1970-01-01 ; elles ne dépendent donc ni destartDate, ni de la position de défilement, ni deweekStarts. Les tranches de sept jours commencent le jeudi ; avecchunkDays: 14etstartDate="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
prefetchtranches de chaque côté (une tranche préchargée peut se trouver avantstartDate). 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 aveccontrol.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
cacheChunkstranches 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-skeletonet utilise le token--super-scheduler-skeleton-base;skeleton: falsela supprime. - Pas d’historique. Les chargements ne créent jamais d’entrées d’annulation, et ils parviennent à
onEventsChangeavecreason: 'load'.
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.
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é :
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é :
- Sans prop
events, ou avecdefaultEvents. 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 appeliezreload(). C’est le choix le plus simple quand les utilisateurs modifient des données chargées par plages. eventscontrôlé plusonEventsChange. Le chargeur n’écrit jamais dans le store. Chaque tranche terminée appelleonEventsChangeavecreason: 'load'eteventscontenant 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.
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, 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 :
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 ?
GET /api/bookings?start=2026-01-01T00:00:00&end=2026-01-15T00:00:00[
{ "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 < endetevent.end > start. Un événement qui commence avant la tranche ou se termine après fait partie de la réponse, commeb-1042etb-1043ci-dessus. Un événement qui se termine exactement àstartn’en fait pas partie. - Des id stables et uniques. Une même réservation doit avoir le même
iddans chaque réponse, et deux événements ne peuvent pas partager un id, toutes ressources confondues. Les id sont comparés strictement :1et'1'sont deux événements différents. - Des dates et heures civiles avec secondes.
startetendsont 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. - L’annulation est bienvenue. Passer le
signalàfetchlibè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().
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
onEventsChangeou vos actions explicites écrivent. - Les mises à jour en direct venant d’autres utilisateurs. Les options de rafraîchissement automatique (
autoRefreshEnabledet apparentées) sont réservées et ne font rien. Interrogez périodiquement le serveur, appliquez ses notifications aveccontrol.events.add/update/remove, ou appelezreload({ start, end })pour les dates concernées. - Les liens et les lignes. Le chargeur ne gère que les événements ; passez
linksetresourcesvous-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é, Annuler et rétablir, Ressources, événements et intervalles et la référence de l’API.