02examples
Optimistic server sync
Every change is committed to a fake server with latency and a reject switch; a rejection springs the block back to where it was.
Drag an event. It lands at once, pulses while the commit is on the wire, and
settles when the server answers. Now flip reject writes and drag again: the
server refuses, the block springs back, and the log underneath records the
KloqChange that was sent and what came of it. The latency slider is the
round-trip; at 2000 ms you can move three things before the first one lands.
liveOptimistic server synconCommitKloqChangedefaultEvents
loading
"use client";
import { useState, useSyncExternalStore } from "react";
import { buildSeedEvents, Kloq, type KloqChange } from "kloq";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { FakeServer } from "./data";
const idsOf = (change: KloqChange): string[] => {
switch (change.type) {
case "add":
return [change.event.id];
case "update":
return [change.id];
case "batch":
return change.changes.flatMap(idsOf);
default:
return [];
}
};
/** Every block renders `data-cal-event="<id>"`; kloq.css pulses the ones carrying `data-syncing`. */
function markSyncing(ids: string[], on: boolean) {
for (const id of ids)
for (const el of document.querySelectorAll(`[data-cal-event="${CSS.escape(id)}"]`))
if (on) el.setAttribute("data-syncing", "");
else el.removeAttribute("data-syncing");
}
export default function ServerSyncExample() {
const [server] = useState(() => new FakeServer());
const [seeds] = useState(() => buildSeedEvents());
const { latency, reject, inFlight, log } = useSyncExternalStore(
server.subscribe,
server.getState,
server.getState,
);
const onCommit = (change: KloqChange) => {
const ids = idsOf(change);
markSyncing(ids, true);
return server.commit(change).finally(() => markSyncing(ids, false));
};
return (
<div className="flex flex-col">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 border-b px-4 py-3 font-mono text-xs text-muted-foreground">
<span className="flex items-center gap-3">
latency
<Slider
aria-label="Latency"
className="w-40"
min={0}
max={2000}
step={100}
value={latency}
onValueChange={(v) => server.setLatency(v as number)}
/>
<span className="min-w-16 whitespace-nowrap tabular-nums text-foreground">{latency} ms</span>
</span>
<Label className="gap-3 font-mono text-xs font-normal text-muted-foreground">
reject writes
<Switch checked={reject} onCheckedChange={(on) => server.setReject(on)} />
</Label>
<span className="ml-auto inline-flex items-center gap-2 tabular-nums">
<span
className={cn(
"size-1.5 rounded-full",
inFlight > 0 ? "bg-red-500 motion-safe:animate-pulse" : "bg-border",
)}
/>
{inFlight > 0 ? `syncing ${inFlight}…` : "idle"}
</span>
</div>
<div className="h-[26rem]">
<Kloq
defaultEvents={seeds}
onCommit={onCommit}
persistence={false}
storage={false}
designMode={false}
responsive={false}
>
<Kloq.Toolbar />
</Kloq>
</div>
<ol className="min-h-24 space-y-1 border-t px-4 py-2 font-mono text-xs text-muted-foreground tabular-nums">
{log.length === 0 && <li>no commits yet — drag an event, then flip the switch and drag again</li>}
{log.map((entry) => (
<li key={entry.id} className="flex gap-3">
<span>#{entry.id}</span>
<span className="text-foreground">{entry.label}</span>
<span className={cn(!entry.ok && "text-red-500")}>{entry.ok ? "ok" : "rejected → reverted"}</span>
</li>
))}
</ol>
</div>
);
}
import type { KloqChange } from "kloq";
export interface CommitEntry {
id: number;
label: string;
ok: boolean;
}
export interface ServerState {
latency: number;
reject: boolean;
inFlight: number;
log: CommitEntry[];
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
/** `update seed-design`, `batch ×3` — what one commit is, in the log. */
export function describeChange(change: KloqChange): string {
switch (change.type) {
case "add":
return `add ${change.event.id}`;
case "update":
return `update ${change.id}`;
case "remove":
return `remove ${change.event.id}`;
case "batch":
return `batch ×${change.changes.length}`;
case "reset":
return "reset";
}
}
/** A stand-in for your API: one round-trip per commit, with a latency knob and a reject switch. */
export class FakeServer {
private state: ServerState = { latency: 600, reject: false, inFlight: 0, log: [] };
private seq = 0;
private listeners = new Set<() => void>();
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getState = () => this.state;
setLatency(latency: number) {
this.set({ latency });
}
setReject(reject: boolean) {
this.set({ reject });
}
/** Rejecting is what makes kloq revert — the thrown error is the whole protocol. */
async commit(change: KloqChange): Promise<void> {
this.set({ inFlight: this.state.inFlight + 1 });
await sleep(this.state.latency);
// kloq ignores the promise of a reset (nothing to revert to), so a reset never fails
const ok = change.type === "reset" || !this.state.reject;
const entry = { id: ++this.seq, label: describeChange(change), ok };
this.set({ inFlight: this.state.inFlight - 1, log: [entry, ...this.state.log].slice(0, 4) });
if (!ok) throw new Error(`server rejected ${entry.label}`);
}
private set(patch: Partial<ServerState>) {
this.state = { ...this.state, ...patch };
for (const listener of this.listeners) listener();
}
}
How it works
onCommitis the seam. kloq calls it with every committed change — a drag, a resize, a create, a delete, a paste — after applying it. Return a promise and the change stands if it resolves; reject and the store reverts, FLIPping the affected blocks back with the cancel spring. That is the whole protocol: the example'sonCommitjust returnsserver.commit(change). See Optimistic sync.FakeServerindata.tsstands in for your API.commitwaitslatencymilliseconds, then throws when the switch is on. It also keeps a small state object — in-flight count, last four results — that the strip reads throughuseSyncExternalStore, so nothing here is React-specific on the server side.- The pulse is one attribute. Every block renders
data-cal-event="<id>";markSyncingsetsdata-syncingon the blocks named by the change while the promise is pending, and kloq's stylesheet animates that selector. The ids come straight off the change:event.idfor anadd,idfor anupdate, the union for abatch. Aremovehas no block left to mark. - The store is uncontrolled —
defaultEventsseeds it,persistence={false}keeps it in memory — so undo, redo and the revert all live inside kloq. The sameonCommitworks unchanged on a controlledeventsarray; there the revert arrives as a call toonEventsChangewith the previous array. See Controlled state. - One wrinkle worth knowing: kloq ignores the promise of a
reset(there is nothing to revert to), so the fake server never rejects one. Reset from the toolbar and the log showsreset okeven with the switch on.
Take it further
- Swap
server.commitfor a route handler:fetch("/api/events", { method: "POST", body: JSON.stringify(change) })andthrowwhen!res.ok. Theupdatevariant carries bothpatchandprev, so a PATCH request and anIf-Matchheader are both a field read away. - A
batchis one change and one request. Applychangesin order inside a transaction and the paste either lands whole or springs back whole. - Tell the user. The revert is visible, but a toast on the catch — before re-throwing — says why; the demo does exactly that.