GrapesJS in React: an editor component that survives StrictMode
A 45-line React component that mounts GrapesJS once, tears it down cleanly, reports changes to React state and passes React 19 StrictMode's double mount. Tested with GrapesJS 0.23.5 in headless Chromium.
TL;DR
- GrapesJS owns its DOM, so in React it is an uncontrolled widget: create it in a mount-only effect, call editor.destroy() in the cleanup, and talk to it through the editor API rather than props.
- Under React 19 StrictMode the effect runs, cleans up and runs again; with destroy() in the cleanup exactly one editor remains, which our test asserts.
- The official @grapesjs/react 2.0.0 wrapper declares a peer range of grapesjs ^0.22.5, which excludes 0.23.x, so this guide uses no wrapper.
What you are building
A GrapesEditor React component, tested on React 19.1.1, that you can drop into an application. It mounts
the GrapesJS editor into a div, hands the editor instance to the parent
once it is ready, reports every change, and destroys the editor when the component unmounts.
The design decision that shapes everything below: GrapesJS is not a React component and does not want to be one. It creates an iframe canvas, renders HTML into it, and keeps its own model of the page. React's job is to give it a DOM node and get out of the way. If you want the opposite model, where your own React components are the editable units, read GrapesJS vs Puck before going further, and the React embedding use case for the wider choice.
Install
npm install grapesjs@0.23.5React is assumed to be in the project already. The example runs on React 19.1.1.
The component
import { useEffect, useRef } from 'react';
import grapesjs, { type Editor, type ProjectData } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
export type GrapesEditorProps = {
/** Read once, on mount. Changing it later does not reload the editor. */
initialProject: ProjectData;
onReady?: (editor: Editor) => void;
onChange?: (project: ProjectData) => void;
};
export function GrapesEditor({ initialProject, onReady, onChange }: GrapesEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Keep the latest callbacks without re-running the mount effect when a parent re-renders.
const callbacks = useRef({ onReady, onChange });
useEffect(() => {
callbacks.current = { onReady, onChange };
}, [onReady, onChange]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const editor = grapesjs.init({
container,
height: '100%',
storageManager: false,
projectData: initialProject,
});
const handleUpdate = () => callbacks.current.onChange?.(editor.getProjectData());
editor.on('update', handleUpdate);
editor.onReady(() => callbacks.current.onReady?.(editor));
return () => {
editor.off('update', handleUpdate);
editor.destroy();
};
// The editor owns its DOM: mount once, tear down on unmount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div ref={containerRef} style={{ height: '100%' }} />;
}Four things in there are deliberate:
- The mount effect has an empty dependency list.
initialProjectis read once. Re-running the effect when a prop changes would destroy and recreate the editor, losing undo history and selection. To load a different project into a live editor, calleditor.loadProjectData(). - Callbacks live in a ref. Parents usually pass inline functions, which change on every render.
Reading them through
callbacks.currentkeeps the latest version without re-mounting. - Cleanup calls
editor.destroy(). Without it, StrictMode's second mount creates a second editor inside the same container, and every unmount leaks the first one. storageManager: false. GrapesJS defaults to autosaving intolocalStorage. In an application you almost always want your own persistence, so turn the default off and choose deliberately.
Using it
import { StrictMode, useCallback, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import type { Editor, ProjectData } from 'grapesjs';
import { GrapesEditor } from './GrapesEditor';
const INITIAL_PROJECT: ProjectData = {
pages: [{ component: '<section class="hero"><h1>Hello from React</h1></section>' }],
};
function App() {
const editorRef = useRef<Editor | null>(null);
const [changes, setChanges] = useState(0);
const [saved, setSaved] = useState('');
const handleReady = useCallback((editor: Editor) => {
editorRef.current = editor;
}, []);
const handleChange = useCallback(() => setChanges((n) => n + 1), []);
const handleAddSection = () => {
editorRef.current?.addComponents('<section><p>Added from a React button</p></section>');
};
const handleSave = () => {
const editor = editorRef.current;
if (!editor) return;
setSaved(JSON.stringify(editor.getProjectData()));
};
return (
<div style={{ display: 'grid', gridTemplateRows: 'auto 1fr', height: '100vh' }}>
<header style={{ display: 'flex', gap: 12, padding: 8 }}>
<button type="button" onClick={handleAddSection}>
Add section
</button>
<button type="button" onClick={handleSave}>
Save
</button>
<span data-testid="changes">{changes}</span>
<output data-testid="saved" hidden>
{saved}
</output>
</header>
<GrapesEditor initialProject={INITIAL_PROJECT} onReady={handleReady} onChange={handleChange} />
</div>
);
}
const root = document.getElementById('app');
if (!root) throw new Error('#app not found');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);The parent keeps the editor in a ref, not in state: the instance never changes after mount and
nothing should re-render because of it. The "Add section" button shows the direction of control,
React calls the editor API, and the update event carries changes back.
What our test checks
The example is bundled with esbuild in development mode, because StrictMode only double-mounts in development, then loaded in headless Chromium. The test asserts that:
- exactly one
.gjs-editorexists after StrictMode's mount, cleanup and remount; - the initial project renders inside the canvas iframe;
- a React button that calls
editor.addComponents()produces anupdateevent that reaches React state; getProjectData()returns JSON with one page containing the added section;- the browser logs no errors.
The script is scripts/verify-guide-examples.mjs in this site's repository and runs in CI on
every change.
Two network requests you did not ask for
Font Awesome from cdnjs. The cssIcons option defaults to Font Awesome 4.7 on
cdnjs.cloudflare.com, which the default panels use for icons. Our test saw this request on every
load. If your Content Security Policy blocks third-party stylesheets, self-host the file and point
cssIcons at it.
Telemetry. GrapesJS 0.23.5 posts the page's domain and the editor version to
app.grapesjs.com once per browser session, skipping hostnames that contain "localhost". The
GrapesJS documentation describes the data collected and the opt-out, which is telemetry: false
in grapesjs.init().
Where this goes next
- Code-split the editor. We measured the GrapesJS core at 294.5 kB gzip in our bundle size benchmark. Put the editor on its own route and load it lazily; the Next.js guide shows one way.
- Persist projects. Saving and loading from a database
replaces
storageManager: falsewith a real backend. - Give users something to drag. Custom blocks adds your own component types and blocks through a plugin.
What this guide does not cover
- Custom UI. The example keeps GrapesJS's default panels. Replacing them with React-rendered panels is a larger project and the main reason teams consider the Studio SDK.
- Rendering React components on the canvas. The canvas holds HTML. Wrapping React components as GrapesJS component types is possible but is not what this component does.
- Server rendering. The component initialises in an effect, so nothing renders on the server. See the Next.js guide for what that means in the App Router.
- @grapesjs/react. We did not test the official wrapper with 0.23.5; see the FAQ below.
Frequently asked questions
- Should I use @grapesjs/react instead of writing my own component?
- It is the official wrapper and worth reading, but version 2.0.0 declares grapesjs ^0.22.5 as its peer dependency, and under npm's rules for 0.x versions that range stops before 0.23.0. We did not test it against 0.23.5. The component in this guide has no dependency beyond grapesjs and React, which is why we chose it. The older grapesjs-react package on npm is marked deprecated in favour of @grapesjs/react.
- Can my existing React components become draggable blocks in GrapesJS?
- Not directly. GrapesJS renders HTML into an iframe and stores a component tree of its own, not React elements. If your design-system components must be the editable units, Puck or Craft.js are built for that, and our GrapesJS vs Puck comparison covers the trade.
- Why does the editor render with zero height?
- GrapesJS writes its height option onto the container's inline style, replacing whatever height the element had. With height: '100%', every ancestor needs a real height. Give the parent a height (the example uses a CSS grid row) or pass an absolute value such as '100vh'.
- How do I save what users build?
- Call editor.getProjectData() and store the JSON, or configure GrapesJS's remote storage. Our save-and-load guide wires it to a Postgres table and documents a 0.23.5 quirk in how failed requests are reported.
Sources
- GrapesJS documentation: Getting Started — accessed 2026-09-17
- GrapesJS API reference: Editor — accessed 2026-09-17
- GrapesJS documentation: Telemetry — accessed 2026-09-17
- npm registry metadata for @grapesjs/react (2.0.0, MIT, peer grapesjs ^0.22.5) — accessed 2026-09-17
- npm registry metadata for grapesjs-react (4.0.3, deprecated) — accessed 2026-09-17
- React documentation: StrictMode — accessed 2026-09-17
Written by the EditorStack Research Team. How we test and what we refuse to publish is on the methodology page.