# Événements contrôlés et callbacks

> Gardez les événements dans le state React avec onEventsChange, donnez une copie au contrôle, suivez l’ordre des callbacks et enregistrez de façon optimiste.

Source: https://superscheduler.org/fr/docs/controlled-state/
Reviewed: 2026-10-07

Passez les événements depuis le state React et réécrivez-les dans onEventsChange, que le contrôle appelle une fois par tâche après un dépôt, un redimensionnement ou un appel à control.events, avec la nouvelle liste, les objets modifiés et supprimés, et un motif. Donnez au contrôle une copie de votre tableau, car il adopte le tableau et le modifie sur place. La bibliothèque ne communique jamais avec votre backend : enregistrez depuis onEventMove pour confirmer avant que la modification soit validée, ou depuis onEventsChange pour enregistrer de façon optimiste et revenir en arrière en cas d’échec.

SuperScheduler Pro propose deux façons de posséder les données d’événements. **Contrôlé** : le state React est la source de vérité, vous le passez dans `events`, et `onEventsChange` vous indique ce que l’utilisateur ou l’API a modifié. **Non contrôlé** : vous fournissez les données initiales avec `defaultEvents` et le contrôle garde sa propre liste. Le mode contrôlé est le bon choix par défaut pour une application qui enregistre les modifications, les affiche ailleurs dans la page ou prend en charge l’annulation.

Cette page explique le modèle contrôlé, ce que reçoit le callback de modification, les règles de propriété du tableau qui le font fonctionner, l’ordre exact des callbacks lors d’un dépôt et le moment où intervient votre backend.

## Le modèle contrôlé
```tsx
// src/ControlledPlanning.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SuperScheduler } from 'super-scheduler'

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

const INITIAL: SuperScheduler.EventData[] = [
  {
    id: 'b-1042',
    resource: 'r101',
    start: '2026-10-02T14:00:00',
    end: '2026-10-05T11:00:00',
    text: 'Booking 1042',
  },
  {
    id: 'b-1043',
    resource: 'r102',
    start: '2026-10-03T14:00:00',
    end: '2026-10-08T11:00:00',
    text: 'Booking 1043',
  },
]

export function ControlledPlanning() {
  // React state is the single source of truth for the events.
  const [events, setEvents] = useState<SuperScheduler.EventData[]>(INITIAL)

  // The control adopts the array it receives and splices it in place: give it its own copy.
  const owned = useMemo(() => events.slice(), [events])

  // Once per task, after a drop, a resize or a control.events call. Handing the same objects back
  // is recognised as an echo: the control does not reload or repaint.
  const onEventsChange = useCallback((args: SchedulerEventsChangeArgs) => {
    setEvents([...args.events])
  }, [])

  // Changes made outside the scheduler go to state; the control picks up the new array.
  const addBlock = () =>
    setEvents((current) => [
      ...current,
      {
        id: `block-${crypto.randomUUID()}`,
        resource: 'r102',
        start: '2026-10-12T00:00:00',
        end: '2026-10-14T00:00:00',
        text: 'Maintenance',
        moveDisabled: true,
        resizeDisabled: true,
      },
    ])

  return (
    <>
      <p>
        {events.length} events{' '}
        <button type="button" onClick={addBlock}>
          Block Room 102
        </button>
      </p>
      <SuperSchedulerComponent
        startDate="2026-10-01"
        days={31}
        scale="Day"
        timeHeaders={TIME_HEADERS}
        resources={ROOMS}
        events={owned}
        onEventsChange={onEventsChange}
      />
    </>
  )
}
```
Vous devriez voir deux réservations et un compteur d’événements. Faites glisser une réservation vers l’autre chambre : elle reste là où vous l’avez déposée, car sa nouvelle position est désormais dans le state React. Appuyez sur le bouton : le compteur augmente et un blocage de maintenance apparaît sur Room 102, verrouillé contre le déplacement et le redimensionnement.

Trois lignes portent le modèle :

1. `useState` contient les événements. Tout ce qui les affiche ou les modifie lit ce state.
2. `useMemo(() => events.slice(), [events])` donne au contrôle **sa propre copie** du tableau (voir [propriété du tableau](#ownership)).
3. `onEventsChange` réécrit dans le state la nouvelle liste du contrôle avec `setEvents([...args.events])`.

Quand le state change pour une autre raison (un formulaire, un push du serveur, le bouton ci-dessus), le nouveau tableau atteint le contrôle comme une prop modifiée, et le contrôle le recharge.

## Ce que reçoit onEventsChange
`onEventsChange` est appelé après une modification du stockage d’événements du contrôle, **au plus une fois par tâche** : plusieurs modifications effectuées dans le même bloc synchrone arrivent ensemble en un seul appel, à la microtâche suivante.

| Argument | Contenu |
|---|---|
| `events` | La liste complète du contrôle après la modification, sous forme d’objets de données |
| `changed` | Les objets ajoutés ou remplacés par cette modification, dans leur nouvel état |
| `removed` | Les objets supprimés ou remplacés par cette modification, dans leur état précédent |
| `reason` | Pourquoi le stockage a changé (ci-dessous) |

| `reason` | Déclenché par |
|---|---|
| `'move'` | Un glisser-déposer validé, y compris au clavier, et les dépôts venus de l’extérieur du planificateur |
| `'resize'` | Un redimensionnement validé |
| `'create'` | `control.events.add()` |
| `'update'` | `control.events.update()` avec un nouvel objet, ou un ajout et une suppression dans la même tâche |
| `'remove'` | `control.events.remove()`, y compris le bouton de suppression intégré (`eventDeleteHandling: 'Update'`) |
| `'history'` | Une annulation ou un rétablissement appliqué par le contrôle via `super-scheduler/history` |
| `'load'` | Vous avez passé d’autres objets événements dans `events`, ou un chargeur de plages a fusionné des événements nouvellement chargés |
| `'api'` | D’autres modifications que la bibliothèque apporte d’elle-même au stockage ; traitez-les comme `'update'` |

Pour un déplacement, `changed` contient le nouvel objet et `removed` l’objet qu’il a remplacé : vous disposez de l’état avant et après sans garder votre propre copie. Les objets de `events` conservent leur identité d’un appel à l’autre tant qu’ils n’ont pas changé : `React.memo` et les sélecteurs qui comparent par référence continuent donc de fonctionner.

## Non contrôlé : defaultEvents
Passez `defaultEvents` au lieu de `events` quand le planificateur peut posséder les données, par exemple dans une vue surtout consultée ou dans un prototype. Le tableau est lu une seule fois, à l’initialisation ; les modifications ultérieures de la prop sont ignorées, avec un avertissement en développement. Si vous passez les deux, `events` l’emporte, également avec un avertissement.

Dans ce mode, lisez les données actuelles dans `control.events.list`, abonnez-vous avec `useScheduler({ track: ['events'] })` de `super-scheduler/hooks`, ou écoutez quand même `onEventsChange`, qui fonctionne dans les deux modes.

## Le contrôle adopte votre tableau
Par souci de rapidité, le contrôle ne copie pas le tableau que vous passez dans `events` : `control.events.list` **est** ce tableau, et les ajouts, suppressions et dépôts le modifient sur place avec `splice`. C’est pourquoi le modèle ci-dessus passe une copie. Sans elle, le contrôle muterait le tableau contenu dans votre state React, à l’insu de React.

La même règle explique les autres comportements de la boucle contrôlée :

- **Les échos ne coûtent rien.** Quand `onEventsChange` stocke `[...args.events]`, React effectue un rendu et le contrôle reçoit un tableau contenant exactement les objets qu’il détient déjà. Il reconnaît l’écho et ne fait rien : ni rechargement, ni repaint.
- **De nouveaux objets provoquent un rechargement.** Quand votre state contient des objets que le contrôle n’a jamais vus (une modification par formulaire, une réponse du serveur), il recharge sa liste depuis le nouveau tableau puis signale `reason: 'load'`. Stocker à nouveau cette liste est un écho : la boucle s’arrête là.
- **Ne gelez pas le tableau** si vous appelez `control.events.add`, `update` ou `remove` : ils le modifient sur place et lèvent une `TypeError` sur un tableau gelé. Les dépôts et les redimensionnements copient d’abord un tableau gelé.

> **Behavior:**
> Un dépôt ne modifie jamais votre objet événement : la bibliothèque le remplace par un nouvel objet, `{ ...old, start, end, resource }`, dont `start` et `end` sont des valeurs `SuperScheduler.Date`. Les setters de l’objet enveloppe de l’événement (`e.start(value)`, `e.end(value)`) sont différents : ils écrivent dans l’objet de données existant. En mode contrôlé, préférez `control.events.update({ ...e.data, end })` avec un nouvel objet, ou modifiez directement votre state.

## Ordre des callbacks lors d’un dépôt
Chaque glisser-déposer suit une séquence fixe. Ce logger la met en évidence :

```ts
// src/tracing.ts
import type { SchedulerProps } from 'super-scheduler'

// Logs every callback of one drag-and-drop, in the order the library calls them.
export const tracing: SchedulerProps = {
  onEventMoving: (args) =>
    console.debug('1. moving (every shadow change)', args.start.value, args.allowed),
  onEventMove: (args) =>
    console.debug('2. move (before the commit, cancelable)', args.newStart.value),
  onEventMoved: (args) =>
    // The store already holds the new times here.
    console.debug(
      '3. moved (after the commit)',
      args.control.events.find(args.e.id())?.start().value,
    ),
  onEventsChange: (args) =>
    console.debug('4. eventsChange (next microtask)', args.reason, args.changed.length),
}
```
1. **`onEventMoving`** s’exécute à chaque changement de l’ombre pendant que l’utilisateur fait glisser. Il peut refuser la position ou l’ajuster (voir [Glisser, redimensionner et règles métier](https://superscheduler.org/fr/docs/drag-resize-rules/)).
2. Au relâchement, si la dernière position a été refusée (par votre règle, un chevauchement, une cellule désactivée), **rien d’autre ne s’exécute** : ni `onEventMove`, ni modification.
3. **`onEventMove`** s’exécute une fois, avant la modification du stockage. Il peut annuler avec `args.preventDefault()`, changer `args.newStart`, `args.newEnd` ou `args.newResource`, ou différer la décision avec `args.async = true` et `args.loaded()`.
4. Le stockage est mis à jour (avec `eventMoveHandling: 'Update'`, la valeur par défaut).
5. **`onEventMoved`** s’exécute après la validation : `args.control.events.find(id)` renvoie déjà les nouveaux horaires.
6. **`onEventsChange`** s’exécute à la microtâche suivante avec `reason: 'move'`.

Le redimensionnement suit la même séquence avec `onEventResizing`, `onEventResize`, `onEventResized` et `reason: 'resize'`. Comme `onEventMoved` s’exécute avant que React ait stocké quoi que ce soit, lisez les nouvelles valeurs dans ses arguments, pas dans votre state.

## Où intervient votre backend
Le planificateur n’appelle jamais de serveur. C’est vous qui décidez quand enregistrer, et deux conceptions solides s’offrent à vous.

### Confirmer avant de valider la modification
Enregistrez dans `onEventMove` ou `onEventResize` avec `args.async = true`, et appelez `args.loaded()` quand le serveur répond ; appelez d’abord `args.preventDefault()` s’il a refusé. D’ici là, l’événement reste à sa place : l’écran n’affiche donc jamais une modification rejetée par le serveur. Le prix à payer est une latence visible à chaque dépôt. Le modèle complet se trouve dans [Confirmer au dépôt](https://superscheduler.org/fr/docs/drag-resize-rules/#async-confirmation).

### Enregistrer de façon optimiste et revenir en arrière en cas d’échec
Acceptez la modification immédiatement, enregistrez en arrière-plan et remettez l’objet précédent si l’enregistrement échoue. `onEventsChange` fournit tout le nécessaire : `changed` est ce qu’il faut enregistrer, `removed` ce qu’il faut restaurer.

```tsx
// src/OptimisticPlanning.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SuperScheduler } from 'super-scheduler'

const iso = (value: SuperScheduler.DateInput) => (typeof value === 'string' ? value : value.value)

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

export function OptimisticPlanning({ rooms, initial }: Props) {
  const [events, setEvents] = useState(initial)
  const owned = useMemo(() => events.slice(), [events])
  const { controlRef } = useSchedulerControl()

  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => {
      // 1. Show the change immediately.
      setEvents([...args.events])
      if (args.reason !== 'move' && args.reason !== 'resize') return

      for (const after of args.changed) {
        // The object this drop replaced: the state to restore if the server says no.
        const before = args.removed.find((item) => item.id === after.id)
        if (before === undefined || after.resource === undefined) continue

        // 2. Persist it.
        saveBooking({
          id: String(after.id),
          resource: String(after.resource),
          start: iso(after.start),
          end: iso(after.end),
        })
          // 3. Revert on failure. Matching by identity leaves a newer change of the same event alone.
          .catch(() => {
            setEvents((current) => current.map((item) => (item === after ? before : item)))
            controlRef.current?.message('The change could not be saved and was undone.')
          })
      }
    },
    [controlRef],
  )

  return (
    <SuperSchedulerComponent
      controlRef={controlRef}
      startDate="2026-10-01"
      days={31}
      scale="Day"
      resources={rooms}
      events={owned}
      onEventsChange={onEventsChange}
    />
  )
}
```
Un dépôt devrait prendre effet immédiatement. Si `saveBooking` est rejeté, la réservation revient à sa place précédente et un message en explique la raison. Le retour arrière s’appuie sur l’identité des objets : si l’utilisateur a déplacé la même réservation entre-temps, la modification plus récente n’est pas touchée.

Quelle que soit la conception choisie, gardez ces responsabilités dans votre application :

- **Validez côté serveur.** Les règles de `onEventMoving` relèvent de l’expérience utilisateur ; le serveur doit vérifier de nouveau les chevauchements, les permissions et les règles métier, car d’autres utilisateurs et d’autres clients modifient les mêmes données.
- **Normalisez ce que vous envoyez.** Les événements déplacés portent des valeurs `SuperScheduler.Date` ; ceux qui n’ont pas été touchés portent vos chaînes. Voir [les valeurs après un glisser](https://superscheduler.org/fr/docs/resources-events-intervals/#after-drag).
- **Adoptez la version du serveur.** Si le serveur renvoie un objet canonique (un nouvel id pour un événement créé, un prix recalculé), remplacez l’objet dans le state. Le contrôle recharge et signale `reason: 'load'`.

## Annulation, volets et chargement par plages
- **Annuler et rétablir.** `createHistory({ apply })` de `super-scheduler/history` peut appliquer l’annulation et le rétablissement à votre state plutôt qu’au contrôle. Voir [Annuler et rétablir](https://superscheduler.org/fr/docs/undo-redo/).
- **Plusieurs volets.** `SchedulerPanes` partage une même liste d’événements entre les volets via `events` contrôlé et `onEventsChange` (ou `defaultEvents`). Voir [Volets et vues enregistrées](https://superscheduler.org/fr/docs/panes-saved-views/).
- **Chargement par plage de dates.** Un chargeur de plages de `super-scheduler/ranges` fusionne ce qu’il charge et le signale via `onEventsChange` avec `reason: 'load'` ; adoptez cette liste. Voir [Chargement par plages](https://superscheduler.org/fr/docs/range-loading/).

→ https://superscheduler.org/fr/examples/field-service-dispatch/
→ https://superscheduler.org/fr/examples/training-rooms/
## Étapes suivantes
- Refusez les déplacements invalides pendant que l’utilisateur fait glisser : [Glisser, redimensionner et règles métier](https://superscheduler.org/fr/docs/drag-resize-rules/).
- Typez vos champs personnalisés de bout en bout : [Champs personnalisés avec EventData&lt;T&gt;](https://superscheduler.org/fr/docs/resources-events-intervals/#custom-fields).
- Accédez au contrôle depuis le code React : [Intégration React](https://superscheduler.org/fr/docs/react-integration/#control).
