Tutorial

Chapter 8: The React surface

Run the meeting-notes editor on React: EditorContextProvider, EditorSurface, EditorToolbar, SimpleEditor and the Toaster, and where the callout would go.

Everything in chapters 2 to 7 has a React twin. The React layer (@skaile/forge-editor/react, shadcn/ui on Radix and Tailwind 4) renders the same framework-free core as the Vue layer: the simple editor, EditorToolbar, the settings panel and modal, and the @, # and / suggestion popups. It uses no Vue, Nuxt UI or Nuxt module.

The runnable reference is examples/react-simple (port 3041); it is built into this page. It has the same sample picker, read-only switch, host /datum command and JSON and Markdown panes as the Vue example from chapter 2, plus @ mentions:

Open in new tab

Start it yourself with bun run --cwd examples/react-simple dev. Quick start: React lists the dependencies, vite.config.ts and index.css an app needs; this chapter shows how the pieces map to what you already know.

The context: EditorContextProvider

Vue has app.use(createEditor()). React has a context object that you own: createEditorContext() from the framework-free @skaile/forge-editor/core takes the same options as the Vue plugin (backend, locale, ai, …), and EditorContextProvider hands it to the tree. The provider never disposes the context; call context.dispose() yourself when the context outlives your app. The toolbar and menus render i-lucide-* names through @iconify/react, so the example preloads the icon collection and the page makes no request to the Iconify API:

examples/react-simple/src/main.tsx
import lucide from '@iconify-json/lucide/icons.json'
import { addCollection } from '@iconify/react'
import { createMemoryBackend } from '@skaile/forge-editor/adapters/memory'
import { createEditorContext } from '@skaile/forge-editor/core'
import { EditorContextProvider } from '@skaile/forge-editor/react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './index.css'

// Preload the icon set: the page makes no request to the Iconify API.
addCollection(lucide)

// The host owns the context; the provider never disposes it. No data layer: documents, dropped files (as blob: URLs),
// mention users and tags, and settings live in the page.
const context = createEditorContext({
  backend: createMemoryBackend({
    users: [
      { id: 'u1', username: 'Ada Lovelace', color: '#7c3aed' },
      { id: 'u2', username: 'Alan Turing', color: '#0ea5e9' },
    ],
    tags: ['idee', 'entwurf', 'wichtig'],
  }),
})

createRoot(document.getElementById('app')!).render(
  <StrictMode>
    <EditorContextProvider context={context}>
      <App />
    </EditorContextProvider>
  </StrictMode>,
)

Notices of app-level models, such as the AI model profiles, go through the context's notifier. The simple editor raises none, so createEditorContext() is enough here. A host of features passes createEditorContext({ notify: reactNotifier }) and mounts sonner's Toaster once (both from @skaile/forge-editor/react); components such as DocumentTree need the Toaster to be mounted.

EditorSurface and EditorToolbar

The React EditorSurface takes the same kind of preset as the Vue one. EditorToolbar is again a sibling that works on the editor you pass it (from onReady), never on a global active editor, and it renders no groups without an editor. The preset follows chapter 3, with the same feature factories:

examples/react-simple/src/App.tsx
// Preset and metadata are read once at mount: module constants.
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature<ComponentType<{ editor: Editor }>>({ palette: EditorCommandPalette }), mentionsFeature()] })
const metadata: EditorMetadata = {
  mentions: [
    { id: 'u1', label: 'Ada Lovelace', color: '#7c3aed' },
    { id: 'u2', label: 'Alan Turing' },
  ],
}

coreContentFeature(), coreUiFeature(), mentionsFeature() and the three integrations have no components and work in both layers. A feature with components has a React entry: mediaFeature from @skaile/forge-editor/media/react, blockUiFeature from @skaile/forge-editor/block-ui/react, and so on. Mixing a Vue feature into a React preset is a type error. Module constants matter more than in Vue: the preset, metadata, abilities, extensions and commands are read once at mount.

The surface itself differs from Vue in the prop names: value and onChange instead of v-model, onReady instead of @ready, and so on:

examples/react-simple/src/App.tsx
            <EditorSurface
              preset={preset}
              metadata={metadata}
              commands={hostCommands}
              value={content}
              editable={editable}
              onReady={setEditor}
              onChange={onChange}
              onContentError={onContentError}
            />
NameMeaning
presetEditorSurface only: defineEditor({ features }) (a ReactPreset); fixed at mount, remount with key to change it.
value / onChangeTipTap JSON document and its edits. A new object replaces the document without an onChange; JSON the editor emitted itself and the host hands back unchanged is ignored, so the round trip keeps caret and undo history.
editableRead-only when false (default true); reactive.
metadataHost lists (EditorMetadata); reactive, pass a constant.
abilities, extensions, commandsAs in Vue; read once at mount.
onReady, onMetadataCreate, onContentErrorThe Vue events ready, metadataCreate and contentError as callbacks.
settingsUi'internal' (default) mounts the settings modal inside the surface; 'external' calls onSettingsOpen(tab?) and the host renders EditorSettingsPanel or EditorSettingsModal.

The one-liner: SimpleEditor

Without own features, SimpleEditor from @skaile/forge-editor/react/simple builds the preset itself (core-content and core-ui, plus the features in its add prop) on an EditorSurface. The ref exposes { editor, openSettings, preset }:

import { EditorToolbar } from '@skaile/forge-editor/react'
import { SimpleEditor } from '@skaile/forge-editor/react/simple'
import type { Editor } from '@tiptap/core'
import { useState } from 'react'

export function App() {
  const [editor, setEditor] = useState<Editor | null>(null)
  return (
    <>
      <EditorToolbar editor={editor ?? undefined} />
      <SimpleEditor onReady={setEditor} />
    </>
  )
}

Settings in React

useEditorSettings() from @skaile/forge-editor/react reads the app's settings from the context: { settings, update, set, ready, synced }, where update(draft => { … }) mutates a copy and stores it and set(value) replaces it. Persistence is the same as in Vue: the localStorage key tiptap-editor:settings, optionally synced per account.

Where the callout would go

There is no React callout in the repository: the Hinweis block of the examples is a Vue example, and the React sample says so („Hinweis-Blöcke gehören nicht zur Bibliothek; das Vue-Beispiel bringt einen eigenen mit.“). Porting it is small, because the node is already framework-free:

  • node.ts from chapter 4 stays unchanged. It imports only @tiptap/core and @skaile/forge-editor/core/schema.
  • The second file attaches the view: Callout.extend({ addNodeView: () => ReactNodeViewRenderer(CalloutView) }) with ReactNodeViewRenderer from @tiptap/react and a React CalloutView built from NodeViewWrapper and NodeViewContent.
  • Path A is the same: pass extensions (a module constant) and commands (a module function) to EditorSurface or SimpleEditor.
  • Path B is the same: calloutFeature() fills no component slot, so it is an EditorFeature<never> and fits a React list as it is. Add blockUiFeature() from @skaile/forge-editor/block-ui/react for the hover actions.

What you have now

The same editor on React: a context you own, an EditorSurface over a preset of the features from chapter 3, a toolbar, the SimpleEditor shortcut and the settings hook. You have finished the tutorial: from a simple editor to composed features, an own block, a server with collaboration and AI, on both surfaces.

Next

  • Advanced editor in React for the full catalog on React (AdvancedEditor, composeEditor, collaboration).
  • Exports for the inventory of every entry of the package.
Copyright © 2026