Build a React interface for Word tracked changes
Build per-change accept and reject controls with useReview. Save a DOCX with accepted, rejected, and pending revisions.
A contract arrives with three proposed edits. You want to keep the payment change, reject the delivery change, and leave the third pending. Build a React review panel that makes each decision separately, then saves the reviewed DOCX.
Try that workflow below. Create the proposals, accept one card, reject another, and leave the last card untouched. Save and reopen the document to check the result.
Add the editor and review module
This tutorial uses an existing React application. Install matching versions of these packages:
npm install @docx-editor.dev/react @docx-editor.dev/core @docx-editor.dev/proThe editor is open source. The review module requires a Pro license. Your input can be any DOCX with tracked changes. To create revisions in a background job, follow Create DOCX redlines in Node.js.
Save the following as ContractReview.tsx. It receives the document bytes from your file picker or server.
The next two sections provide its ReviewDecisionPanel and DownloadReview components.
"use client";
import { DocxEditor } from "@docx-editor.dev/react";
import { reviewModule } from "@docx-editor.dev/pro/react";
import { ReviewDecisionPanel } from "./ReviewDecisionPanel";
import { DownloadReview } from "./DownloadReview";
import "@docx-editor.dev/core/styles/editor.css";
const modules = [reviewModule({ pane: { opening: "manual" } })];
export function ContractReview({ document }: { document: Uint8Array }) {
return (
<DocxEditor.Root document={document} modules={modules}
author="Contract reviewer" mode="suggesting" reviewDisplayMode="all-markup">
<DownloadReview />
<DocxEditor.Toolbar>
<DocxEditor.Toolbar.Group id="review" hidden />
</DocxEditor.Toolbar>
<div style={{ display: "flex", flexWrap: "wrap", height: 600, overflow: "auto" }}>
<DocxEditor.Viewport style={{ flex: "1 1 480px", minWidth: 0, height: 600, overflow: "auto" }}>
<DocxEditor.Content />
</DocxEditor.Viewport>
<div style={{ flex: "1 1 280px", minWidth: 0 }}>
<ReviewDecisionPanel />
</div>
</div>
</DocxEditor.Root>
);
}DocxEditor.Root provides the editor context. The viewport renders the document; its sibling renders your review controls.
Both must stay inside the root. Keep the module array outside the component so renders do not recreate it.
The example uses a custom panel instead of the built-in review rail.
For the packaged interface, use DocxEditorReview inside the viewport.
Configure fonts for the documents your application opens.
Render one decision per card
Save this component as ReviewDecisionPanel.tsx. The live example above uses this implementation.
The utility classes control appearance; the review behavior does not depend on them.
"use client";
import { useRef, useState } from "react";
import { useReview } from "@docx-editor.dev/pro/react";
/** A custom review queue shared by the live example and its React tutorial. */
export function ReviewDecisionPanel({ className = "" }: { className?: string }) {
const { items, ready, accept, reject, setActive, activeKey, lastRefusal } = useReview({
pairReplacements: true,
});
const [message, setMessage] = useState("");
const heading = useRef<HTMLHeadingElement>(null);
const revisions = items.filter((item) => item.kind === "revision");
return (
<aside aria-label="Review changes" className={`overflow-auto border-line-warm bg-[#fafbf9] p-4 ${className}`}>
<h3 ref={heading} tabIndex={-1} className="font-semibold text-ink-warm outline-offset-4 focus-visible:outline-2 focus-visible:outline-forest">
{ready ? `${revisions.length} pending ${revisions.length === 1 ? "change" : "changes"}` : "Loading changes…"}
</h3>
{ready && revisions.length === 0 && (
<div className="mt-5 rounded-xl border border-dashed border-line-warm bg-white px-4 py-6 text-center">
<span aria-hidden="true" className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-forest/10 text-lg text-forest">✓</span>
<p className="font-medium text-ink-warm">No pending tracked changes.</p>
<p className="mt-2 text-xs text-ink-mute">Proposed edits appear here for review.</p>
</div>
)}
<ul className="mt-4 grid gap-3">
{revisions.map((item) => (
<li key={item.key} aria-label={`Change by ${item.author || "unknown author"}: ${item.text}`} className={`rounded-xl border bg-white p-3 shadow-sm ${activeKey === item.key ? "border-forest/50 ring-1 ring-forest/15" : "border-line-warm"}`}>
<div className="mb-3 flex items-center gap-2 text-xs text-ink-mute">
<span aria-hidden="true" className="flex size-6 shrink-0 items-center justify-center rounded-full bg-forest/10 font-semibold text-forest">{(item.author || "R").slice(0, 1)}</span>
<span>{item.author || "Unknown author"}</span>
</div>
<p className="my-3 break-words text-sm leading-relaxed">
{item.replacedText && <><del className="rounded bg-red-50 px-1 text-red-800 decoration-red-500">{item.replacedText}</del>{" "}</>}
<span className="rounded bg-emerald-50 px-1 text-emerald-900">{item.text}</span>
</p>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button type="button" disabled={!item.activatable} className="mr-auto min-h-8 rounded px-1 text-forest underline underline-offset-4 outline-offset-2 focus-visible:outline-2 disabled:opacity-50"
onClick={() => {
if (!setActive(item.key)) setMessage("Could not locate this change in the document.");
}}>
Locate
</button>
{(["accept", "reject"] as const).map((action) => (
<button key={action} type="button" disabled={item.readOnly}
className={`min-h-8 rounded-md border px-2.5 py-1 font-medium outline-offset-2 focus-visible:outline-2 disabled:opacity-50 ${action === "accept" ? "border-forest bg-forest text-white hover:bg-forest-deep" : "border-line-warm text-ink-warm hover:bg-paper-ivory"}`}
onClick={() => {
const applied = action === "accept" ? accept(item) : reject(item);
setMessage(applied ? `Change ${action === "accept" ? "accepted" : "rejected"}.` : "Could not resolve this change. Check document permissions.");
if (applied) heading.current?.focus();
}}>
{action === "accept" ? "Accept" : "Reject"}
</button>
))}
</div>
{item.readOnly && <p className="mt-2 text-xs">This revision cannot be resolved in the editor.</p>}
{lastRefusal?.key === item.key && <p className="mt-2 text-xs" role="alert">Review action refused: {lastRefusal.code}.</p>}
</li>
))}
</ul>
<p className="mt-3 text-sm" role="status">{message}</p>
</aside>
);
}useReview() subscribes to document changes. Accepting or rejecting a card updates the document and the list.
Do not remove cards from a separate local array: a failed operation must leave its card visible.
pairReplacements: true groups a deletion and its replacement into one decision.
Accept keeps the proposed text. Reject restores the original text.
Each call receives the current item, not a list index or every revision by that author.
The hook also returns comments. This panel filters to revisions because comments need different controls, such as resolve and reopen. It keeps unsupported revisions visible with disabled buttons. An empty visible list must not hide revisions that your editor cannot resolve.
Both accept(item) and reject(item) return a boolean.
Check that result before reporting success. lastRefusal.code gives a diagnostic when the engine refuses the action.
A successful action removes its button, so the panel returns keyboard focus to its heading.
Save the reviewed DOCX
Save this as DownloadReview.tsx. editor.save() serializes the current document, including revisions you left pending.
It does not accept those remaining changes.
"use client";
import { useState } from "react";
import { useDocxEditor } from "@docx-editor.dev/react";
export function DownloadReview() {
const editor = useDocxEditor();
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
async function download() {
if (!editor || saving) return;
setSaving(true);
setError("");
try {
const bytes = await editor.save();
const url = URL.createObjectURL(new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}));
const link = document.createElement("a");
link.href = url;
link.download = "contract.reviewed.docx";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch {
setError("Could not save the review. Try again.");
} finally {
setSaving(false);
}
}
return <>
<button type="button" disabled={!editor || saving} onClick={() => void download()}>
{saving ? "Saving…" : "Download reviewed DOCX"}
</button>
{error && <p role="alert">{error}</p>}
</>;
}For server storage, upload these bytes with the version you originally opened. Use a conditional save to detect another reviewer's intervening edits.
Check a mixed review
Use the demo's three changes to test independent decisions:
| Card | Decision | After saving and reopening |
|---|---|---|
| Payment: 7 days → 30 days | Accept | 30 days remains without a pending revision |
| Delivery: 14 days → 21 days | Reject | 14 days remains without a pending revision |
| Review: approval → written approval | Leave pending | The proposed edit remains reviewable with its author |
Download the result and reopen it in your target application as well. The browser demo checks DOCX Editor's save and reopen behavior. It does not run Microsoft Word.
Also test a document with unsupported revisions and a document opened without edit permissions. A refused action must not remove its card or report success.
Next steps
Use the Node.js tutorial to generate the file this interface reviews. If an AI agent creates the proposals, validate its proposed edits before adding revisions.
