noteloom

Offline persistence

A fully offline editor — no server, no internet required. Documents auto-save to IndexedDB (a native browser API, no added dependency — the whole editor stays zero-runtime-dependency) and reload themselves on the next visit. This is what powers this site's own My Notes app.

Quick start: usePersistedDocument

jsx
import { useEditor, NoteloomEditor, usePersistedDocument } from 'noteloom';

function App() {
  const editor = useEditor({ doc: myStarterDoc });
  const { isLoaded } = usePersistedDocument({ store: editor.store, docId: 'my-document-id' });

  if (!isLoaded) return <p>Loading…</p>;
  return <NoteloomEditor editor={editor} />;
}

On mount, this loads whatever was last saved under docId (if anything) and replaces the store's content with it; every edit after that — typing, structural changes, even changes arriving from a collaborating peer via CollabSession — is auto-saved back, debounced (default 500ms of quiet) so a full-document write doesn't fire on every keystroke. Different docIds are stored independently, so one browser can hold many separate documents (e.g. keyed by page/route).

Options & return value

OptionTypeRequiredWhat it does
storeEditorStore | HistoryYesThe store to load into / persist from — must already exist (created via useEditor() or your own useMemo). This hook does not construct one.
docIdstringYesWhich saved document to load/save. Changing it re-runs the load for the new id.
debounceMsnumberNo — default 500How long to wait after the last change before writing to IndexedDB.
onError(err) => voidNoCalled if a load or save fails (e.g. IndexedDB unavailable in a private-browsing context).

Returns { isLoaded }false until the initial IndexedDB read resolves, so you can show a loading state instead of briefly flashing default content that's about to be replaced.

Known edge case: an edit made in the narrow window between mount and the initial load resolving can be discarded once the load applies (whatever was actually persisted always wins) — acceptable for a hydrate-on-mount pattern, and not reachable in practice outside deliberately racing it.

Lower-level pieces

If usePersistedDocument's all-in-one behavior doesn't fit — a non-React host app, custom load/save timing, wanting to list/delete saved documents from a "My Notes"-style index page — the raw operations it's built on are all exported individually:

js
import {
  savePersistedDocument,
  loadPersistedDocument,
  deletePersistedDocument,
  listPersistedDocumentIds,
} from 'noteloom';

await savePersistedDocument('my-document-id', editor.store.toJSON());
const doc = await loadPersistedDocument('my-document-id'); // null if nothing saved yet
await deletePersistedDocument('my-document-id');
const ids = await listPersistedDocumentIds(); // every docId currently stored
FunctionSignatureWhat it does
savePersistedDocument(docId, doc) => Promise<void>Writes a full document JSON (the same shape store.toJSON() returns) under docId.
loadPersistedDocument(docId) => Promise<doc | null>Reads it back — null if nothing was ever saved under that id.
deletePersistedDocument(docId) => Promise<void>Removes a saved document entirely (e.g. a "Delete note" action).
listPersistedDocumentIds() => Promise<string[]>Every docId currently stored — enough to render a "My Notes" list page without keeping your own separate index.

Just the debounced auto-save half, if you want to handle the initial load yourself:

js
import { createAutoPersistence } from 'noteloom';

// You load the initial document yourself (e.g. already have it from an API);
// this just wires the debounced auto-save half.
const { stop, flush } = createAutoPersistence({
  store: editor.store,
  docId: 'my-document-id',
  debounceMs: 500, // default, shown explicitly
  onError: (err) => console.error('Failed to save', err),
});

// later, when the store is no longer in use:
flush(); // persist any pending change before tearing down
stop();

createAutoPersistence fires on every store mutation, local or remote — it doesn't distinguish, so a document being live-collaborated on stays persisted the same as one edited solo. stop() unsubscribes and cancels any pending debounced save, but does not flush a pending save first — call flush() beforehand if the most recent edit must be persisted before tearing down (e.g. navigating away).

This is standalone — works with a solo, non-collaborating store just as well as one wired to CollabSession (see Live collaboration) — a collaborated-on document also gets saved locally, so it survives even after every peer disconnects. This only makes the editing work offline; if the app itself is loaded from a dev server or web host, opening it for the very first time (or after clearing cache) still needs that host reachable once — the next section covers that separate concern.

Offline app shell (PWA)

usePersistedDocument makes the document offline-capable; it doesn't make the app itself loadable with no network at all — that needs a service worker precaching the HTML/JS/CSS, which is a build-level concern (the exact list of files to cache is whatever your bundler outputs), not something a runtime library can inject. noteloom doesn't ship a service worker implementation for that reason. Instead:

  • Use a standard PWA build plugin — vite-plugin-pwa is the common choice for Vite, and requires no noteloom-specific configuration.
  • useServiceWorkerUpdate() (exported from the package) is the one genuinely reusable piece:
jsx
import { useServiceWorkerUpdate } from 'noteloom';

function UpdateBanner() {
  const { updateAvailable, applyUpdate } = useServiceWorkerUpdate();
  if (!updateAvailable) return null;
  return <button onClick={applyUpdate}>Update available — reload</button>;
}

It watches for a newly-installed service worker sitting in the "waiting" state (the standard signal a fresh build is ready) and gives you { updateAvailable, applyUpdate } to surface and act on it. Works with any service worker registration, however it got there — it only observes, it doesn't register one itself.

Service workers only activate on a real production build, not a dev server. To try the offline app-shell behavior locally: build, then serve the build output (e.g. vite preview), load it once online, then disconnect entirely and reload — the app shell still loads, and editing/persistence both keep working, since IndexedDB has no network dependency of its own.
This site's own My Notes app demonstrates the same offline-first idea end to end — every note lives entirely in the browser (its own localStorage-backed store, predating usePersistedDocument), nothing ever sent to a server. For a new app, usePersistedDocument above is the more direct path to the same result.