# GrapesJS in React: an editor component that survives StrictMode

*Source: https://www.editorstack.cc/guides/grapesjs-react-integration — last updated 2026-09-17. Licensed CC BY 4.0 with attribution to EditorStack.*

*Tested with GrapesJS 0.23.5. The code on this page runs in CI from examples/guides/grapesjs-react-integration in the site repository.*

## Summary

- 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](/libraries/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](/compare/grapesjs-vs-puck) before going further, and the
[React embedding use case](/use-cases/embed-page-builder-in-react-app) for the wider choice.

## Install

```bash
npm install grapesjs@0.23.5
```

React is assumed to be in the project already. The example runs on React 19.1.1.

## The component

```tsx title="GrapesEditor.tsx"
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.** `initialProject` is 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, call `editor.loadProjectData()`.
- **Callbacks live in a ref.** Parents usually pass inline functions, which change on every render.
  Reading them through `callbacks.current` keeps 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 into `localStorage`. In an
  application you almost always want your own persistence, so turn the default off and choose
  deliberately.

## Using it

```tsx title="App.tsx"
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:

1. exactly one `.gjs-editor` exists after StrictMode's mount, cleanup and remount;
2. the initial project renders inside the canvas iframe;
3. a React button that calls `editor.addComponents()` produces an `update` event that reaches
   React state;
4. `getProjectData()` returns JSON with one page containing the added section;
5. 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](/research/bundle-size-benchmark-2026). Put the editor on its own route
  and load it lazily; the [Next.js guide](/guides/grapesjs-nextjs) shows one way.
- **Persist projects.** [Saving and loading from a database](/guides/grapesjs-save-load-database)
  replaces `storageManager: false` with a real backend.
- **Give users something to drag.** [Custom blocks](/guides/grapesjs-custom-blocks) adds your own
  component types and blocks through a plugin.

> **Sponsored — GJS.Market:** GrapesJS and React resources — GJS.Market's React page collects wrappers, presets and plugins for running GrapesJS inside React apps. GJS.Market funds this site; check any plugin's supported GrapesJS version before installing.

## 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](/libraries/grapesjs-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](/guides/grapesjs-nextjs) 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

1. [GrapesJS documentation: Getting Started](https://grapesjs.com/docs/getting-started.html) — accessed 2026-09-17
2. [GrapesJS API reference: Editor](https://grapesjs.com/docs/api/editor.html) — accessed 2026-09-17
3. [GrapesJS documentation: Telemetry](https://grapesjs.com/docs/guides/Telemetry.html) — accessed 2026-09-17
4. [npm registry metadata for @grapesjs/react (2.0.0, MIT, peer grapesjs ^0.22.5)](https://registry.npmjs.org/@grapesjs/react) — accessed 2026-09-17
5. [npm registry metadata for grapesjs-react (4.0.3, deprecated)](https://registry.npmjs.org/grapesjs-react) — accessed 2026-09-17
6. [React documentation: StrictMode](https://react.dev/reference/react/StrictMode) — accessed 2026-09-17
