02examples
Custom persistence adapter
Uncontrolled events saved through your own adapter — sessionStorage with a versioned payload and a per-tenant namespace, plus a tenant switcher.
Two tenants, two calendars. Move something for acme, switch to globex, switch back: the move is still there, and globex never saw it. Reload the tab and both survive; close the tab and both are gone — that is sessionStorage's contract, and exactly what a docs page should leave behind. clear this tenant wipes one copy and restarts it from its seeds.
liveCustom persistence adapterpersistencestoragePersistenceAdapterreadPayload
loading
"use client";
import { useMemo, useState } from "react";
import { Kloq } from "kloq";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { seedsFor, sessionAdapter, storageKey, TENANTS, type Tenant } from "./data";
export default function CustomPersistenceExample() {
const [tenant, setTenant] = useState<Tenant>("acme");
const [generation, setGeneration] = useState(0);
const adapter = useMemo(() => sessionAdapter(tenant), [tenant]);
const seeds = useMemo(() => seedsFor(tenant), [tenant]);
const clear = () => {
adapter.clear();
setGeneration((n) => n + 1);
};
return (
<div className="flex flex-col">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b px-4 py-2.5 font-mono text-xs text-muted-foreground">
<span>tenant</span>
<div role="group" aria-label="Tenant" className="inline-flex rounded-lg border bg-background p-0.5">
{TENANTS.map((t) => (
<button
key={t}
type="button"
aria-pressed={tenant === t}
onClick={() => setTenant(t)}
className={cn(
"rounded-md px-2.5 py-1 transition-colors",
tenant === t ? "bg-foreground text-background" : "hover:text-foreground",
)}
>
{t}
</button>
))}
</div>
<span className="truncate">sessionStorage · {storageKey(tenant)}</span>
<Button size="xs" variant="outline" className="ml-auto font-mono text-xs" onClick={clear}>
clear this tenant
</Button>
</div>
<div className="h-[26rem]">
{/* the key remounts the store: a new tenant loads its own copy, a clear starts from seeds */}
<Kloq
key={`${tenant}:${generation}`}
storage={`tenant-${tenant}`}
persistence={adapter}
defaultEvents={seeds}
designMode={false}
responsive={false}
>
<Kloq.Toolbar />
</Kloq>
</div>
</div>
);
}
import {
buildSeedEvents,
dateOnlyAfter,
dateOnlyAt,
instantAt,
PAYLOAD_VERSION,
readPayload,
resolveView,
type CalEvent,
type PayloadV2,
type PersistenceAdapter,
} from "kloq";
export const TENANTS = ["acme", "globex"] as const;
export type Tenant = (typeof TENANTS)[number];
export const storageKey = (tenant: Tenant) => `kloq-example:${tenant}:events`;
export interface TenantAdapter extends PersistenceAdapter {
clear(): void;
}
/**
* sessionStorage, one key per tenant. The payload is kloq's own versioned envelope
* (`{ v: 2, events }`), so `readPayload` does the checking: an unknown version or a
* corrupt copy reads as null — "nothing stored" — and the seeds stay.
*/
export function sessionAdapter(tenant: Tenant): TenantAdapter {
const key = storageKey(tenant);
return {
load() {
try {
const raw = sessionStorage.getItem(key);
return raw ? (readPayload(JSON.parse(raw))?.events ?? null) : null;
} catch {
return null;
}
},
save(events) {
const payload: PayloadV2 = { v: PAYLOAD_VERSION, events };
try {
sessionStorage.setItem(key, JSON.stringify(payload));
} catch {
// quota / private mode — the change still applies in memory
}
},
clear() {
sessionStorage.removeItem(key);
},
};
}
/** Each tenant starts from its own week, so a switch is visibly a different calendar. */
export function seedsFor(tenant: Tenant, now = new Date()): CalEvent[] {
if (tenant === "acme") return buildSeedEvents(now);
const week = resolveView("week", 1, now);
const at = (day: number, hour: number, minute = 0) => instantAt(week, day, hour * 60 + minute);
return [
{ id: "globex-allhands", title: "Globex all-hands", start: at(0, 10), end: at(0, 11), color: "emerald" },
{ id: "globex-inspection", title: "Cooling tower inspection", start: at(1, 14), end: at(1, 16), color: "amber", location: "Plant 3" },
{ id: "globex-hank", title: "1:1 with Hank", start: at(2, 9, 30), end: at(2, 10), color: "violet" },
{ id: "globex-supplier", title: "Supplier review", start: at(3, 13), end: at(3, 14, 30), color: "blue" },
{ id: "globex-offsite", title: "Offsite", allDay: true, start: dateOnlyAt(week, 4), end: dateOnlyAfter(week, 4), color: "rose" },
];
}
How it works
persistence={adapter}hands the uncontrolled store aPersistenceAdapter:loadruns once after mount and returns the stored events ornullfor "nothing stored, keep the seeds";savereceives the full array on every committed change, never mid-drag;clearis what Reset calls. The contract is on the Persistence page.- The payload is versioned, and kloq checks it.
savewrites{ v: PAYLOAD_VERSION, events }— the same envelope the built-inlocalStorageAdapteruses — andloadreads it back throughreadPayload, which validates every event and returnsnullfor a version it does not know, a corrupt copy, or a non-empty payload that validates to nothing. A newer build's data, or a hand-edited key, can never blank the calendar. - Tenancy is two keys.
sessionAdapter("acme")closes overkloq-example:acme:events, andstorage="tenant-acme"gives kloq's own preference keys — theme, scheme, density, zoom — the same namespace (tenant-acme-densityand so on). Both are per-tenant, so a switch is a different calendar in every respect. - Switching remounts.
<Kloq key={tenant}>throws the internal store away and builds a new one from that tenant'sdefaultEvents, thenloadswaps in the stored copy before first paint. The clear button does the same with agenerationcounter after callingadapter.clear(), so the calendar comes back on its seeds instead of saving an empty array. - Why sessionStorage: it is scoped to the tab and evaporates with it, so an
example can persist for real without leaving anything in a visitor's
browser. For an app,
localStorageAdapter("myapp:calendar")is the same adapter over the durable store.
Take it further
- Over the network:
load: () => fetch(url).then((r) => r.json()).then((p) => readPayload(p)?.events ?? null)andsave: (events) => fetch(url, { method: "PUT", body: JSON.stringify({ v: PAYLOAD_VERSION, events }) }).loadmay return a promise; the seeds render until it resolves. - IndexedDB: the same two functions over
get(key)/set(key, payload)from a helper likeidb-keyval. Keep the envelope — the version lives in the data, not the key. - Per change instead of full array: pair the adapter with
onCommitand send eachKloqChangeto your API while the adapter keeps a local copy for instant loads.