ProseMirror plugin state: track changes without confusing them with saved content

A ProseMirror plugin can keep application state alongside the document. Define its initial value in state.init, return a new value from state.apply, and use a PluginKey to read it from the current EditorState. Use transaction metadata to send an application event into that state. The plugin's value is separate from the document nodes and marks. Plugin state reference

Consider an original internal-policy editor that labels a draft “Changed since review.” Moving the cursor should leave the label alone. Editing a sentence should increment a local revision, and a reviewer acknowledging an older revision should not accidentally clear a newer edit.

Count local document changes in one place

This fragment belongs in an existing ProseMirror application, with compatible packages and a configured document schema:

import { Plugin, PluginKey } from 'prosemirror-state';

export const reviewRevisionKey = new PluginKey('policyReviewRevision');

export const reviewRevisionPlugin = new Plugin({
  key: reviewRevisionKey,
  state: {
    init() { return { revision: 0, acknowledged: 0 }; },
    apply(tr, previous) {
      const revision = previous.revision + (tr.docChanged ? 1 : 0);
      const event = tr.getMeta(reviewRevisionKey);
      const acknowledged = event?.type === 'acknowledge'
        && Number.isInteger(event.revision)
        && event.revision >= previous.acknowledged
        && event.revision <= revision
          ? event.revision
          : previous.acknowledged;
      return { revision, acknowledged };
    }
  }
});

Register reviewRevisionPlugin in the state's plugins array. The returned object is a new value; the reducer does not mutate the previous state's object. A metadata-only acknowledgement does not increment revision because it does not change the document.

This counter measures applied document-changing transactions in this editor session. It is neither a server version nor an audit log. Undo also changes the document, and collaborative remote edits can produce transactions too. Name the label and interpret the counter to match that scope.

Read the current value and acknowledge the reviewed snapshot

After the view applies a state update, derive the label from that state:

const current = reviewRevisionKey.getState(view.state);
const needsReview = current.revision !== current.acknowledged;
statusElement.textContent = needsReview
  ? 'Changed since review'
  : 'No changes since review';

Here view is the existing EditorView and statusElement is the application's visible status element. Integrate this update into the application's state-update path or a plugin view. A plugin view can provide update() and destroy() hooks for UI lifecycle work. Keep event listeners and other resources paired with cleanup. PluginView reference

When preparing a review, capture both the document snapshot and the current local revision. If the reviewer acknowledges that snapshot later, send its captured revision rather than rereading the newest value:

// reviewedRevision was captured with the document the reviewer saw.
view.dispatch(view.state.tr.setMeta(reviewRevisionKey, {
  type: 'acknowledge',
  revision: reviewedRevision
}));

Suppose the reviewer received revision 4 and the author has since reached revision 5. Acknowledging 4 leaves the label at “Changed since review.” Substituting 5 at acknowledgement time would claim the reviewer saw a change they never received. Transporting and authorizing a review response remain application responsibilities.

Keep reducers, transaction hooks, and side effects distinct

Use state.apply to derive the plugin value from the applied transaction. Avoid starting network saves inside that reducer. A save needs its own request identity, completion handling, and error state; replaying a transaction must not silently repeat an external operation.

filterTransaction can reject a transaction before application. appendTransaction can return an additional transaction after an update. Neither is needed for the counter above. If another feature needs normalization, make its appended transformation converge: once the document meets the rule, return no further transaction. Otherwise, plugins that keep changing each other's output can repeatedly trigger more work. Transaction hooks

Create transaction objects from the state they will actually apply to. Do not retain an old state.tr while awaiting a server response and dispatch it into a newer state. The acknowledgement above starts with view.state.tr when the response is ready and carries the older review revision as metadata.

Verify the label against the policy the reviewer read

Move the cursor, edit one sentence, acknowledge an earlier snapshot, then acknowledge the current one. Inspect the plugin state after each step. Those cases separate selection changes, content changes, stale responses, and current review acknowledgement.

Plugin state does not automatically travel with state.doc.toJSON(). Optional state-field serialization requires an explicit application policy and the relevant editor-state serialization mapping. This session counter can simply reset when opening another policy if that is what the product promises. EditorState serialization

For asynchronous persistence, the autosave consistency guide explains the related problem of associating a response with the content actually sent. A review acknowledgement is a different event from a successful server save. End the policy review with the right document snapshot acknowledged and the application's save result reported independently.

Keep reading