Chapter 8: The React surface
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:
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:
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:
// 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:
<EditorSurface
preset={preset}
metadata={metadata}
commands={hostCommands}
value={content}
editable={editable}
onReady={setEditor}
onChange={onChange}
onContentError={onContentError}
/>
| Name | Meaning |
|---|---|
preset | EditorSurface only: defineEditor({ features }) (a ReactPreset); fixed at mount, remount with key to change it. |
value / onChange | TipTap 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. |
editable | Read-only when false (default true); reactive. |
metadata | Host lists (EditorMetadata); reactive, pass a constant. |
abilities, extensions, commands | As in Vue; read once at mount. |
onReady, onMetadataCreate, onContentError | The 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.tsfrom chapter 4 stays unchanged. It imports only@tiptap/coreand@skaile/forge-editor/core/schema.- The second file attaches the view:
Callout.extend({ addNodeView: () => ReactNodeViewRenderer(CalloutView) })withReactNodeViewRendererfrom@tiptap/reactand a ReactCalloutViewbuilt fromNodeViewWrapperandNodeViewContent. - Path A is the same: pass
extensions(a module constant) andcommands(a module function) toEditorSurfaceorSimpleEditor. - Path B is the same:
calloutFeature()fills no component slot, so it is anEditorFeature<never>and fits a React list as it is. AddblockUiFeature()from@skaile/forge-editor/block-ui/reactfor 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.
Chapter 7: AI
Add aiFeature() to the meeting-notes editor, provide the /api/ai endpoints and keys, and describe the callout to the model with aiContext.
Feature composition and entries
Compose an editor from features and presets, pick the right entries, and choose the integration depth from no data layer to a Nuxt server.