Events & Data

Saving Changes

One function receives every change the user makes. The calendar shows the result before the function runs, slides it back if the function throws, and lets you decide when the server answers with something else.

One Function

onMutate is called with a Mutation after the change is already on screen. Do the save there and return; throw to undo. Without onMutate, every change commits at once and the bound array is the record.

<script lang="ts">
  import { Scheduler, type EventInput, type Mutation } from '@sv5ui/scheduler';

  let events = $state<EventInput[]>([/* ... */]);

  // Runs after the change is already on screen. Throw or reject to undo it.
  async function onMutate(mutation: Mutation) {
    const response = await fetch('/api/events', { method: 'POST', body: JSON.stringify(mutation) });
    if (!response.ok) throw new Error(await response.text());
  }
</script>

<Scheduler bind:events {onMutate} onError={(mutation, error) => toast.error(String(error))} />
interface Mutation<T> {
  id: string;                        // 'm1', 'm2', ... unique per scheduler
  kind: 'create' | 'move' | 'resize' | 'update' | 'delete';
  eventId: string;
  before: SchedulerEvent<T> | null;  // null on create
  after: SchedulerEvent<T> | null;   // null on delete
}

// What produces each kind
// create   drag over empty slots, Enter on a slot, a drop from dragSource, createPanel's create()
// move     drag an event to another slot, day or the all-day row
// resize   drag an event's edge
// update   eventPanel's update(changes)
// delete   Delete key, the popover's delete button, eventPanel's remove()

What produces each kind

kindProduced by
createDrag over empty slots, Enter on a slot, a drop from dragSource, createPanel create()
moveDrag an event to another slot, day or the all-day row
resizeDrag the edge of an event
updateeventPanel update(changes)
deleteDelete key, the popover button, eventPanel remove()

A Simulated Server

Choose how the server behaves, then move, resize, create or delete events. Try dragging two different events quickly with a long delay: each has its own queue, so neither waits for the other. Try dragging the same event twice: the second save waits for the first.

Returns nothing: the optimistic state is confirmed

Oct 19 – 25, 2026

GMT+7
Mon 19
Tue 20
Wed 21
Thu 22
Fri 23
Sat 24
Sun 25
All day
Lunch break

Server log

  • Nothing yet. Interact with the calendar.
<script lang="ts">
  import { Scheduler, type EventInput, type Mutation } from '@sv5ui/scheduler';

  let events = $state<EventInput[]>([/* ... */]);

  // Runs after the change is already on screen. Throw or reject to undo it.
  async function onMutate(mutation: Mutation) {
    const response = await fetch('/api/events', { method: 'POST', body: JSON.stringify(mutation) });
    if (!response.ok) throw new Error(await response.text());
  }
</script>

<Scheduler bind:events {onMutate} onError={(mutation, error) => toast.error(String(error))} />

When the Save Fails

A throw or a rejected promise rolls the event back to before, sliding it to its old place unless the reader prefers reduced motion, and announces the rollback to screen readers. Then onError is called with the mutation and what was thrown, which is the place for a toast. Saves already queued behind it for the same event are dropped, because they were built on a state that no longer exists.

async function onMutate(mutation: Mutation) {
  const response = await fetch('/api/events', { method: 'POST', body: JSON.stringify(mutation) });
  // Any throw or rejection rolls the event back to `before`, with an animation,
  // and calls onError with what was thrown. Later saves queued for the same
  // event are dropped, since they were built on a state that no longer exists.
  if (response.status === 409) throw new Error('Someone else changed this event');
  if (!response.ok) throw new Error(`Save failed: ${response.status}`);
}

function onError(mutation: Mutation, error: unknown) {
  toast.error(`Could not save ${mutation.after?.title ?? mutation.before?.title}`, {
    description: error instanceof Error ? error.message : String(error)
  });
}

Returning the Server's Version

Return the event as the server stored it, as an EventInput, and the scheduler compares it with what it sent. The comparison covers the fields the calendar draws; it leaves out data, so a server that adds a version stamp to your payload is not a conflict. Whatever comes back is written into the store, so the stamp reaches your array.

// Return the event as the server stored it and the scheduler compares it with
// what it sent. Same event: confirmed. Different: onConflict decides.
async function onMutate(mutation: Mutation): Promise<MutationResult> {
  const response = await fetch('/api/events', { method: 'POST', body: JSON.stringify(mutation) });
  return response.json();   // { id, title, start, end, ... } as EventInput
}

// Compared: id, start, end, title, allDay, calendarId, color, background, editable, resourceId,
// and recurrence field by field. Not compared: data. A server that only touches data is treated as agreeing.

What is compared

FieldsCompared
id, start, endYes
title, allDay, calendarId, color, background, editable, resourceIdYes
dataNo. A server that changes only data agrees with the client
recurrenceYes, field by field: a server that changes the rule is a conflict

What onMutate may return, and what happens

ReturnsThen
nothing, or a resolved promiseConfirmed. If the array was replaced meanwhile, the confirmed event is written back
the same eventConfirmed, the same way
the event with another idThe temporary id is dropped and the server id adopted; no conflict for the id
a different eventonConflict picks keep-server or keep-local; the default is keep-server
a throw or rejectionRolled back to before with an animation, queued saves for the event dropped, onError called
an event that cannot be readTreated as a rejection: rolled back, onError called

Conflicts

When the server's version differs, onConflict receives both and returns a side. keep-server, the default, moves the calendar to the server's version with the same animation as a rollback and announces that the event was updated elsewhere. keep-local keeps what the user did. Pick "Answer with its own version" above to see both.

<Scheduler
  bind:events
  {onMutate}
  onConflict={(mutation, server) => {
    // 'keep-server': the calendar takes the server's version, with the same
    //   animation as a rollback, and announces that the event changed elsewhere.
    // 'keep-local': the user's version stands; the server is expected to accept it.
    return server.editable === false ? 'keep-server' : 'keep-local';
  }}
/>

<!-- Without onConflict the server wins -->

Ids Assigned by the Server

A created event carries a temporary id until the server answers. Return the stored event with the id your database gave it and the scheduler adopts that id in place, without treating the change as a conflict. Pick "Assign a new id on create" above, drag across empty slots, and watch the log.

// A created event carries a temporary id, 'event-<time>-<n>'. Return the stored
// event with the id your database assigned and the scheduler adopts it: the
// temporary event is removed, the stored one takes its place, and no conflict
// is raised for the id alone.
async function onMutate(mutation: Mutation): Promise<MutationResult> {
  if (mutation.kind === 'create') {
    const saved = await api.create(mutation.after!);   // { id: 'evt_8f3a', ... }
    return saved;
  }
  await api.update(mutation.eventId, mutation.after);
}

// One limit: if the user moves the new event again before the server answers,
// that second save still carries the temporary id. Lock the event until it has
// a real id if your API cannot accept the temporary one.

Ordering

Saves to different events run in parallel; saves to the same event run one after another. That makes a version check on the server straightforward: the second save of an event always carries the version the first one returned.

// Saves to different events run in parallel. Saves to the same event run in
// order, each waiting for the previous one, so a fast second drag never
// overtakes the first. If a save fails, the ones queued behind it for that
// event are dropped.

// This is what makes an optimistic version check safe:
async function onMutate(mutation: Mutation) {
  const version = mutation.before?.data?.version ?? 0;
  const saved = await api.update(mutation.eventId, { ...mutation.after, version });
  return saved;   // carries version + 1 in data; not compared, but written back
}

Refetching While a Save Is in Flight

Replacing the array from a refetch does not undo a save that has not answered yet. When the server confirms, the confirmed state is written back, so the calendar ends where the user put the event rather than where the refetch found it.

// A refetch that replaces the array while a save is in flight does not undo the
// save. When the server confirms, the confirmed state is written back into the
// store, and from there into your array.
async function reload() {
  events = await (await fetch('/api/events')).json();
}

Sending Dates to Your API

after.start and after.end are ZonedDateTime values. toAbsoluteString() gives the UTC instant most APIs store; toString() keeps the zone. Either comes back in through the same door. See Time Zones.

// A complete handler against a REST API that stores UTC
async function onMutate(mutation: Mutation<Meeting>): Promise<MutationResult<Meeting>> {
  const { kind, eventId, after } = mutation;
  const body = after && {
    title: after.title,
    start: after.start.toAbsoluteString(),   // '2026-09-16T02:00:00.000Z'
    end: after.end.toAbsoluteString(),
    allDay: after.allDay ?? false,
    calendarId: after.calendarId,
    data: after.data
  };

  switch (kind) {
    case 'create': {
      const response = await fetch('/api/events', { method: 'POST', body: JSON.stringify(body) });
      return response.json();                                 // adopts the server id
    }
    case 'delete':
      await fetch(`/api/events/${eventId}`, { method: 'DELETE' });
      return;
    default: {                                                // move, resize, update
      const response = await fetch(`/api/events/${eventId}`, { method: 'PATCH', body: JSON.stringify(body) });
      return response.json();                                 // compared for conflicts
    }
  }
}

Without onMutate

Every change commits at once and the bound array is the record. For a calendar that lives in the browser, persisting that array is the whole job.

<script lang="ts">
  import { useLocalStorage } from 'sv5ui';
  import { Scheduler, type EventInput } from '@sv5ui/scheduler';

  // Without onMutate every change commits at once. The bound array is the
  // record, so persisting it is enough. After a change its dates are
  // ZonedDateTime values, which JSON would flatten into fields, so write them
  // as strings; the scheduler reads the '...[Asia/Ho_Chi_Minh]' form back.
  const stored = useLocalStorage<EventInput[]>('calendar', [], {
    stringify: (events) =>
      JSON.stringify(events.map((e) => ({ ...e, start: String(e.start), end: String(e.end) })))
  });
</script>

<Scheduler bind:events={stored.current} />

Props

The props of the save pipeline.

PropTypeDefault
eventsEventInput<T>[]-
onMutate(m: Mutation<T>) => MutationResult<T> | Promise<...>-
onConflict(m: Mutation<T>, server: SchedulerEvent<T>) => ConflictResolution'keep-server'
onError(m: Mutation<T>, error: unknown) => void-
middlewareStoreMiddleware<T>[][]