All articlesWordPress

React patterns inside Gutenberg blocks — what transfers and what doesn't

May 25, 20266 min read

Gutenberg is built on React. If you know React, you can write blocks — but several patterns that are idiomatic in React applications will break or behave unexpectedly in the block editor. The differences are not obvious from the outside.

useState works in edit.js, but not across the save/edit boundary

React state in a block editor component works exactly as expected within the edit.js scope. The problem occurs when developers expect state to persist, or try to use state to produce output that ends up in the database.

save() does not have access to component state. save() is not a React component that runs alongside edit.js — it is a serialization function that WordPress calls to produce the post content HTML. It receives only the block's registered attributes. No hooks, no state, no side effects.

// Wrong mental model — save() cannot access useState
export function Edit({ attributes, setAttributes }) {
  const [isExpanded, setIsExpanded] = useState(false);
  // isExpanded is internal editor UI state — it cannot reach save()
}

export function save({ attributes }) {
  // attributes only — no component state available here
  return <div className={`accordion ${attributes.defaultOpen ? 'open' : ''}`}>...</div>;
}

If a value needs to influence the frontend output, it must be a registered attribute. State is for transient editor UI — showing a modal, tracking a hover state, managing a loading indicator during a media upload. Anything that needs to survive and appear in the rendered HTML belongs in attributes.

useEffect gotchas in the block editor

useEffect works in block edit components, but the execution environment has a different rhythm than a standard React application. The block editor re-renders blocks frequently: on sidebar panel open/close, on selection changes, on undo/redo, on adjacent block edits.

// This runs more than you expect
useEffect(() => {
  console.log('attributes changed');
}, [attributes]);

Effects that depend on attributes fire on every attribute change across the entire block, including sidebar controls. This is often fine — but effects with expensive operations (API calls, DOM measurements, external library initialisation) become performance problems.

Effects that initialise third-party libraries are especially fragile. The editor React tree can unmount and remount blocks during edit operations. An effect that calls library.init(ref.current) needs a cleanup function that destroys the instance, otherwise you initialise multiple instances on the same DOM node.

useEffect(() => {
  const instance = SomeLibrary.init(ref.current, config);
  return () => instance.destroy(); // cleanup on unmount/re-render
}, [config]);

For frontend library initialisation, the right answer is usually to not use useEffect at all. Use viewScript and vanilla JS instead.

Component composition: blocks are registered globally, not imported

In a React application, components are imported and composed. In Gutenberg, blocks are registered globally with registerBlockType and the block editor manages the component tree.

This means you cannot compose a parent block by importing a child block's Edit component. Child blocks are rendered by the editor runtime when the user inserts them. The parent block declares what blocks are allowed inside it via allowedBlocks in useInnerBlocksProps, and the editor handles rendering the children.

// Wrong — blocks are not composed by importing their components
import { Edit as SlideEdit } from '../slide/edit';

export function Edit() {
  return (
    <div>
      <SlideEdit /> {/* This does not work as expected */}
    </div>
  );
}

// Right — declare allowed children, let the editor manage them
export function Edit() {
  const innerBlocksProps = useInnerBlocksProps(
    useBlockProps(),
    { allowedBlocks: ['myplugin/slide'] }
  );
  return <div {...innerBlocksProps} />;
}

You can import shared UI components (panel controls, reusable form elements) between blocks. What you cannot do is compose the block tree by importing other blocks' Edit components.

The attributes system as a replacement for component state

Attributes are the block's persistent state. They are defined in block.json, set via setAttributes, and available in both edit.js and save.js. Treating them like component state — but with the understanding that they serialize to the database — clarifies when to use each.

// block.json
{
  "attributes": {
    "columns": { "type": "integer", "default": 3 },
    "showCaption": { "type": "boolean", "default": true },
    "captionColor": { "type": "string", "default": "#000000" }
  }
}
// edit.js — setAttributes is like setState for persistent values
export function Edit({ attributes, setAttributes }) {
  const { columns, showCaption, captionColor } = attributes;

  return (
    <>
      <InspectorControls>
        <PanelBody title="Layout">
          <RangeControl
            label="Columns"
            value={columns}
            onChange={(val) => setAttributes({ columns: val })}
            min={1}
            max={6}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        {/* render using attributes */}
      </div>
    </>
  );
}

The rule: if a value should affect the frontend output, it is an attribute. If a value is only relevant to the editor UI experience and does not need to persist, it is component state.

InspectorControls — the mental model React developers find confusing

In React, sidebar content is typically rendered by the component that owns the layout. In Gutenberg, InspectorControls renders into the block editor's sidebar via a portal, regardless of where it appears in the JSX tree.

export function Edit({ attributes, setAttributes }) {
  return (
    <>
      {/* This renders in the sidebar, not in the block */}
      <InspectorControls>
        <PanelBody title="Settings">
          <ToggleControl
            label="Show caption"
            checked={attributes.showCaption}
            onChange={(val) => setAttributes({ showCaption: val })}
          />
        </PanelBody>
      </InspectorControls>

      {/* This renders in the block canvas */}
      <div {...useBlockProps()}>
        {attributes.showCaption && <p>{attributes.caption}</p>}
      </div>
    </>
  );
}

The portal mechanism means you can safely split InspectorControls into a separate component and import it into Edit:

function BlockInspector({ attributes, setAttributes }) {
  return (
    <InspectorControls>
      {/* all sidebar controls */}
    </InspectorControls>
  );
}

export function Edit({ attributes, setAttributes }) {
  return (
    <>
      <BlockInspector attributes={attributes} setAttributes={setAttributes} />
      <div {...useBlockProps()}>{/* block content */}</div>
    </>
  );
}

Extracting controls into BlockInspector keeps Edit readable as blocks accumulate settings.

When to reach for vanilla JS instead of React

If a behavior only runs on the frontend — slider initialization, accordion toggle, video lazy loading — there is no reason to involve React. Write it in view.js, register it as viewScript in block.json, and keep the React component entirely within the editor.

The block editor's React is for the editor experience. The frontend is static HTML plus whatever the viewScript initializes. Keeping these separate produces smaller frontend bundles, avoids React-specific issues in non-React environments, and makes the code easier to reason about for each context.


Farhan Shafi
Farhan Shafi
Full-Stack Developer