React custom UI setup
Bind React controls to the same public SuperDoc UI controller.
superdoc/ui/react provides a React provider and hooks over the framework-neutral controller. Use it when React should render your toolbar or panels while SuperDoc continues to own the document canvas and editing behavior.
This page builds a hybrid interface: React renders the toolbar, and the remaining built-in surfaces stay at their defaults because the example passes no ui. Add ui: false when the application should render every surface, or turn off individual keys as you replace them. Choose an interface covers the three modes.
Complete Custom UI controller setup first if the controller lifecycle is new to you.
Install the public package
Use the same superdoc package for the Editor, controller, and React binding:
pnpm add superdoc react react-domThe superdoc/ui/react subpath is part of superdoc. Do not install or import the removed v1 headless-toolbar packages.
Bind the Editor to the provider
This example loads contract.docx automatically and adds four application-owned controls: undo, bold, document mode, and fit-to-width.
import { useEffect, useRef, useState } from 'react';
import { SuperDoc } from 'superdoc';
import type { CommandExecutionResult } from 'superdoc/ui';
import { SuperDocUIProvider, useSetSuperDoc, useSuperDocCommand, useSuperDocUI } from 'superdoc/ui/react';
import 'superdoc/style.css';
type ReportExecution = (label: string, result: CommandExecutionResult) => void;
function describeExecution(label: string, result: CommandExecutionResult): string {
if (result === false) return `${label} is unavailable.`;
if (typeof result === 'object' && !result.success) return result.failure.message;
return `${label} updated.`;
}
export function App() {
return (
<SuperDocUIProvider>
<CustomToolbar />
<Editor />
</SuperDocUIProvider>
);
}
function CustomToolbar() {
// Toggling Bold on and then off reports the same text twice. Storing the
// message alone would let React skip the second update, so the count keys a
// span *inside* the live region: the region element itself stays mounted (a
// region inserted already-populated is not reliably announced) while its
// content changes on every execution.
const [status, setStatus] = useState({ id: 0, message: 'Toolbar ready.' });
const reportExecution: ReportExecution = (label, result) =>
setStatus((previous) => ({ id: previous.id + 1, message: describeExecution(label, result) }));
return (
<>
<div aria-label='Document controls' role='toolbar'>
<CommandButton id='undo' label='Undo' reportExecution={reportExecution} />
<CommandButton id='bold' label='Bold' reportExecution={reportExecution} toggle />
<DocumentModeSelect reportExecution={reportExecution} />
<CommandButton id='zoom-fit-width' label='Fit width' reportExecution={reportExecution} />
</div>
<p aria-live='polite' role='status'>
<span key={status.id}>{status.message}</span>
</p>
</>
);
}
function CommandButton({
id,
label,
reportExecution,
toggle = false,
}: {
id: string;
label: string;
reportExecution: ReportExecution;
toggle?: boolean;
}) {
const ui = useSuperDocUI();
const state = useSuperDocCommand(id);
const execute = async () => {
if (!ui) return;
reportExecution(label, await ui.commands.executeAsync(id));
};
return (
<button
aria-pressed={toggle ? state.active : undefined}
disabled={!state.enabled}
onClick={() => void execute()}
title={state.reason ?? label}
type='button'
>
{label}
</button>
);
}
function DocumentModeSelect({ reportExecution }: { reportExecution: ReportExecution }) {
const ui = useSuperDocUI();
const state = useSuperDocCommand('document-mode');
const mode = typeof state.value === 'string' ? state.value : 'editing';
const setMode = async (value: string) => {
if (!ui) return;
reportExecution('Document mode', await ui.commands.executeAsync('document-mode', value));
};
return (
<select
aria-label='Document mode'
disabled={!state.enabled}
onChange={(event) => void setMode(event.currentTarget.value)}
value={mode}
>
<option value='editing'>Editing</option>
<option value='suggesting'>Suggesting</option>
<option value='viewing'>Viewing</option>
</select>
);
}
function Editor() {
const setSuperDoc = useSetSuperDoc();
const editorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!editorRef.current) return;
const superdoc = new SuperDoc({
selector: editorRef.current,
document: '/contract.docx',
onReady: ({ superdoc: readySuperDoc }) => setSuperDoc(readySuperDoc),
});
return () => superdoc.destroy();
}, [setSuperDoc]);
return <div ref={editorRef} style={{ height: '70vh' }} />;
}
Copy the tracked-changes fixture to your app's public directory as contract.docx, or update the document URL.
SuperDocUIProvider publishes the bound Editor's own controller, superdoc.ui. For a real SuperDoc it does not create one. useSetSuperDoc() returns the stable callback that binds the ready SuperDoc instance.
The React component still owns the Editor instance. Destroy it in the effect cleanup. The provider does not destroy an Editor-owned controller: superdoc.destroy() does that, so unmounting the provider or binding a different Editor leaves the controller intact for anything else reading it.
Read command state through hooks
useSuperDocCommand(id) subscribes the component to one command. Before the Editor is ready, it returns a safe disabled state. After binding, it updates when the selection, document mode, or command availability changes.
Use the returned values directly:
enabledcontrols whether the action can run.activedrives toggle state such as Bold.valuecarries the current document mode or zoom value when the command provides one.reasonexplains why a known command cannot run.
Do not mirror these values into another React state object. The hook is already the reactive source of truth.
Report command outcomes
Command execution can return false, true, or a structured receipt. A resolved promise does not prove that the document changed. The example reports all three outcomes through an aria-live status instead of discarding the result from executeAsync().
Use the same rule before saving, navigating, or closing a workflow that depends on the command. When a receipt has success: false, show its failure.message or translate its stable failure code into product-specific guidance.
Repeated outcomes have to stay announceable. Toggling Bold on and then off reports the same message twice, and React skips a state update that sets an identical string, so the live region would go silent for the second execution. Store the message with an incrementing id, then key an element inside the region on that id, as the example does. Keying the region element itself would unmount and replace it, and a live region inserted already populated is not reliably announced either, so the container has to stay mounted while its content changes.
Keep controls live when the document changes
There are two different replacement lifecycles:
| What changes | What the React binding does |
|---|---|
The document inside the current SuperDoc instance | Call replaceFile(). Keep the existing provider binding. Command hooks update from the same controller. |
The entire SuperDoc instance | Call setSuperDoc() with the new instance from its onReady, then destroy each instance your component created. |
Use the ready instance your component already owns when replacing only its document:
import type { SuperDoc } from 'superdoc';
export async function replaceDocument(superdoc: SuperDoc, file: File): Promise<void> {
const result = await superdoc.replaceFile(file);
const state = result && typeof result === 'object' && 'state' in result ? result.state : null;
if (state !== null && state !== 'review-ready' && state !== 'editing-ready') {
throw new Error('SuperDoc could not open the selected DOCX.');
}
}Do not create a new UI controller after replaceFile(). Doing so splits your toolbar from the controller used by the rest of that SuperDoc instance.
After either path, verify behavior rather than only checking that controls rendered. Select text and run Bold. Change the font or mode. Make an edit and confirm Undo becomes enabled. If the application switches documents through tabs, repeat those checks after returning to an earlier tab.
Keep one controller per Editor
Do not call createSuperDocUI() inside a component that already uses SuperDocUIProvider. That factory builds a second, independently owned controller; the provider publishes the Editor's own superdoc.ui when useSetSuperDoc() binds it.
You cannot call ui.destroy() from a component either: the hooks return the borrowed handle, which omits the method. The controller belongs to the Editor, and destroying it would leave the built-in toolbar and every other consumer of that instance reading dead state.
Place controls that use the hooks inside the provider. A hook outside the provider throws an explicit error instead of silently creating an unrelated controller.
Continue with Custom UI overview, or compare this approach with the built-in toolbar.