Operation Replay Substrate

PreviousNext

Remote editing in Slate starts with operations and commits. Slate does not choose your network layer, CRDT, persistence model, or awareness protocol. This walkthrough shows the raw replay substrate an adapter can build on.

Start with a local editor

We'll use a normal editor first. Local writes still happen through editor.update, and the runtime records one commit for the whole update.

import { createEditor } from '@platejs/slate'
 
const editor = createEditor()
 
editor.update(
  (tx) => {
    tx.text.insert('!')
    tx.nodes.insert(
      { type: 'paragraph', children: [{ text: 'four' }] },
      { at: [3] }
    )
  },
  { tag: ['local-edit', 'collab-export'] }
)
 
const commit = editor.read((state) => state.value.lastCommit())
import { createEditor } from '@platejs/slate'
 
const editor = createEditor()
 
editor.update(
  (tx) => {
    tx.text.insert('!')
    tx.nodes.insert(
      { type: 'paragraph', children: [{ text: 'four' }] },
      { at: [3] }
    )
  },
  { tag: ['local-edit', 'collab-export'] }
)
 
const commit = editor.read((state) => state.value.lastCommit())

The tag option is a cheap lifecycle label. metadata is the typed policy channel for history, remote import, and selection behavior.

Export operations

A commit contains the operations that changed the document plus the metadata Slate computed while applying them.

const sharedStateKeys = new Set(['document.title', 'page.settings'])
const commit = editor.read((state) => state.value.lastCommit())
 
if (commit) {
  const statePatches = commit.statePatches.filter((patch) =>
    sharedStateKeys.has(patch.key)
  )
 
  if (commit.operations.length > 0 || statePatches.length > 0) {
    sendToPeers({
      metadata: commit.metadata,
      operations: commit.operations,
      statePatches,
      tags: commit.tags,
      selectionBefore: commit.selectionBefore,
      selectionAfter: commit.selectionAfter,
    })
  }
}
const sharedStateKeys = new Set(['document.title', 'page.settings'])
const commit = editor.read((state) => state.value.lastCommit())
 
if (commit) {
  const statePatches = commit.statePatches.filter((patch) =>
    sharedStateKeys.has(patch.key)
  )
 
  if (commit.operations.length > 0 || statePatches.length > 0) {
    sendToPeers({
      metadata: commit.metadata,
      operations: commit.operations,
      statePatches,
      tags: commit.tags,
      selectionBefore: commit.selectionBefore,
      selectionAfter: commit.selectionAfter,
    })
  }
}

Operations and shared state patches are the document replay contract. Your adapter can translate them into its own transport format, persist them, batch them, or merge them with a CRDT. Slate only requires that the operations and state patches you replay are valid Slate payloads.

Commit operations use the serialized document form: primary-document operations omit root, and extra-root operations keep root. Replay remote batches through the base editor/runtime when a message can contain both primary-document and extra-root edits; root-bound editor views are for local commands scoped to that root.

Bulk edits are still operations. A paste can publish one replace_fragment operation that replaces a child slice and carries the selection before and after the replacement. If your adapter can replay Slate operations, replay it as-is. If your CRDT needs finer-grained edits, lower replace_fragment inside the adapter, not inside Slate's paste path.

Import remote operations

Remote operations enter through tx.operations.replay(...). This is the explicit operation replay path.

editor.update(tx => {
  tx.operations.replay(message.operations)
}, {
  tag: ['collaboration', 'remote-import'],
  metadata: {
    collab: { origin: 'remote', saveToHistory: false },
    history: { mode: 'skip' },
    selection: { dom: 'preserve' },
  },
})
editor.update(tx => {
  tx.operations.replay(message.operations)
}, {
  tag: ['collaboration', 'remote-import'],
  metadata: {
    collab: { origin: 'remote', saveToHistory: false },
    history: { mode: 'skip' },
    selection: { dom: 'preserve' },
  },
})

Importing operations produces a normal commit. Subscribers, history, extension listeners, and React projection code all observe the same commit shape.

If the remote message includes document state-field changes, replay those patches in the same transaction.

const sharedStateKeys = new Set(['document.title', 'page.settings'])
const statePatches = (message.statePatches ?? []).filter((patch) =>
  sharedStateKeys.has(patch.key)
)
 
editor.update(
  (tx) => {
    tx.operations.replay(message.operations)
    tx.statePatches.replay(statePatches)
  },
  {
    tag: ['collaboration', 'remote-import'],
    metadata: {
      collab: { origin: 'remote', saveToHistory: false },
      history: { mode: 'skip' },
      selection: { dom: 'preserve' },
    },
  }
)
const sharedStateKeys = new Set(['document.title', 'page.settings'])
const statePatches = (message.statePatches ?? []).filter((patch) =>
  sharedStateKeys.has(patch.key)
)
 
editor.update(
  (tx) => {
    tx.operations.replay(message.operations)
    tx.statePatches.replay(statePatches)
  },
  {
    tag: ['collaboration', 'remote-import'],
    metadata: {
      collab: { origin: 'remote', saveToHistory: false },
      history: { mode: 'skip' },
      selection: { dom: 'preserve' },
    },
  }
)

Subscribe to commits

Use editor.subscribeCommit when an adapter needs to observe committed state. The listener receives the change summary for each commit.

const sharedStateKeys = new Set(['document.title', 'page.settings'])
 
const unsubscribe = editor.subscribeCommit((change) => {
  const documentValue = editor.read((state) => state.value.get())
  const statePatches = change.statePatches.filter((patch) =>
    sharedStateKeys.has(patch.key)
  )
 
  saveDocument(documentValue)
 
  if (change.metadata.collab?.origin === 'remote') return
 
  if (change.operations.length === 0 && statePatches.length === 0) {
    return
  }
 
  sendToPeers({
    operations: change.operations,
    statePatches,
    tags: change.tags,
    dirty: change.dirty,
  })
})
const sharedStateKeys = new Set(['document.title', 'page.settings'])
 
const unsubscribe = editor.subscribeCommit((change) => {
  const documentValue = editor.read((state) => state.value.get())
  const statePatches = change.statePatches.filter((patch) =>
    sharedStateKeys.has(patch.key)
  )
 
  saveDocument(documentValue)
 
  if (change.metadata.collab?.origin === 'remote') return
 
  if (change.operations.length === 0 && statePatches.length === 0) {
    return
  }
 
  sendToPeers({
    operations: change.operations,
    statePatches,
    tags: change.tags,
    dirty: change.dirty,
  })
})

Send operations and filtered state patches to peers. Save the full state.value.get() document value to your database, not to the adapter transport, unless your adapter strips local persisted state first.

Call the returned function when the adapter disconnects.

unsubscribe()
unsubscribe()

That's the adapter loop: local update, export the commit operations, import remote operations with tx.operations.replay(...), and subscribe to committed snapshots.

Commit metadata

The commit is the unit adapters should reason about. It groups every primitive write inside one editor.update call.

FieldUse
operationsThe Slate operations that changed the document.
statePatchesState-field changes. Filter to shared adapter-owned keys before exporting remote payloads.
tagsAdapter/app metadata such as local-edit or remote-import.
metadataTyped policy for history, remote import, selection, and origin.
classesRuntime classification, such as text or structural changes.
selectionBeforeThe model selection before the commit.
selectionAfterThe model selection after the commit.
dirty.pathsPaths touched by the commit.
dirty.runtimeIdsLocal runtime ids touched by the commit.
dirty.topLevelRangeTop-level document range affected by the commit.
snapshotChangedWhether the document snapshot changed.
selectionChangedWhether the model selection changed.
textChangedWhether text content changed.

Adapters should treat this as an observation contract. If your transport layer needs a different representation, derive it from the commit instead of reading mutable editor fields.

Runtime ids are local

Runtime ids are useful inside one editor instance because they survive local path changes. They are not part of the operation payload and should not be stored as remote identifiers.

const { path, runtimeId } = editor.read((state) => {
  const runtimeId = state.runtime.idAt([1])
 
  return {
    path: runtimeId ? state.runtime.pathOf(runtimeId) : null,
    runtimeId,
  }
})
const { path, runtimeId } = editor.read((state) => {
  const runtimeId = state.runtime.idAt([1])
 
  return {
    path: runtimeId ? state.runtime.pathOf(runtimeId) : null,
    runtimeId,
  }
})

Use runtime ids for local projection, target rebasing, and DOM/runtime lookup. Use your adapter's own document positions or awareness ids for remote cursors, presence, and persistence.

Adapter ownership

Slate owns the document model, operations, snapshots, commits, runtime ids, and transaction boundaries. Your adapter owns the distributed system around them.

OwnerResponsibilities
SlateApply operations, produce commits, normalize the document, expose snapshots, rebase local runtime targets.
AdapterChoose the network layer, translate operations if needed, resolve concurrent edits, persist remote state, own awareness and presence.
ReactRender the current projection and subscribe to the runtime through Slate React.

Keeping those owners separate is what lets Plate, CRDT adapters, custom storage, and local-only editors share the same raw Slate substrate without making the editor object grow adapter-specific namespaces.

Hocuspocus transport

@platejs/yjs accepts a YjsProviderLike. Hocuspocus stays at the application edge: wrap its provider so provider.document is exposed as doc, then pass that adapter to createYjsExtension.

Run the local Hocuspocus server:

bun start:yjs
bun start:yjs

Then run the examples site and open /examples/yjs-hocuspocus:

bun serve
bun serve

The server mirrors the Potion deployment shape without requiring Potion's Prisma document table. It loads and stores binary Yjs snapshots under .tmp/yjs-documents and writes a JSON debug view of the Slate value next to each snapshot.

SLATE_YJS_PORT=4444
SLATE_YJS_HOST=0.0.0.0
SLATE_YJS_PATH=/yjs
SLATE_YJS_TIMEOUT=10000
SLATE_YJS_DEBOUNCE=2000
SLATE_YJS_MAX_DEBOUNCE=10000
SLATE_YJS_STORAGE_DIR=.tmp/yjs-documents

NEXT_PUBLIC_SLATE_YJS_URL=ws://localhost:4444/yjs
NEXT_PUBLIC_SLATE_YJS_ROOM=slate-yjs-hocuspocus-demo
SLATE_YJS_PORT=4444
SLATE_YJS_HOST=0.0.0.0
SLATE_YJS_PATH=/yjs
SLATE_YJS_TIMEOUT=10000
SLATE_YJS_DEBOUNCE=2000
SLATE_YJS_MAX_DEBOUNCE=10000
SLATE_YJS_STORAGE_DIR=.tmp/yjs-documents

NEXT_PUBLIC_SLATE_YJS_URL=ws://localhost:4444/yjs
NEXT_PUBLIC_SLATE_YJS_ROOM=slate-yjs-hocuspocus-demo

Redis is optional for local development and useful when scaling more than one Hocuspocus instance:

SLATE_YJS_REDIS_ENABLED=1
SLATE_YJS_REDIS_HOST=127.0.0.1
SLATE_YJS_REDIS_PORT=6379
SLATE_YJS_REDIS_ENABLED=1
SLATE_YJS_REDIS_HOST=127.0.0.1
SLATE_YJS_REDIS_PORT=6379

For local auth smoke tests, set a server token and pass the matching public demo token:

SLATE_YJS_AUTH_TOKEN=local-write-token
NEXT_PUBLIC_SLATE_YJS_TOKEN=local-write-token
SLATE_YJS_AUTH_TOKEN=local-write-token
NEXT_PUBLIC_SLATE_YJS_TOKEN=local-write-token

Real products should mint short-lived tokens server-side. Do not ship a write token as a public browser variable.

Extension helpers

Extensions can add grouped state and tx helpers for higher-level behavior. They should not add adapter-shaped namespaces directly to the editor object.

import { defineEditorExtension } from '@platejs/slate'
 
editor.extend(
  defineEditorExtension({
    name: 'table-foundation',
    state: {
      table(state) {
        return {
          rowCount() {
            return state.nodes.children().length
          },
        }
      },
    },
    tx: {
      table(tx) {
        return {
          insertRow(text: string) {
            tx.nodes.insert(
              { type: 'paragraph', children: [{ text }] },
              { at: [tx.nodes.children().length] }
            )
          },
        }
      },
    },
  })
)
import { defineEditorExtension } from '@platejs/slate'
 
editor.extend(
  defineEditorExtension({
    name: 'table-foundation',
    state: {
      table(state) {
        return {
          rowCount() {
            return state.nodes.children().length
          },
        }
      },
    },
    tx: {
      table(tx) {
        return {
          insertRow(text: string) {
            tx.nodes.insert(
              { type: 'paragraph', children: [{ text }] },
              { at: [tx.nodes.children().length] }
            )
          },
        }
      },
    },
  })
)

That shape gives product frameworks an integration backbone without turning raw Slate into a framework adapter.

What this page does not cover

This page does not provide a full multiplayer recipe. It does not configure a provider, draw remote cursors, or define a CRDT merge policy. Those belong in adapter packages that can prove their behavior against Slate's operation and browser contracts.

Collaboration adapters build on commits for observation, operations for replay, tags for routing, and local runtime ids for projection.