Creating Block Plugins - SubBlocks

In the previous section, we built a fully functional Callout block with rich text, data management, and read-only mode. That's great for standalone blocks, but what happens when you need blocks that are intimately related to each other? Before we dive into the solution, let's explore a fundamental problem that every block editor faces.
The List Problem: Two Bad Solutions
Think about how HTML represents an ordered list. One <ol> element containing multiple <li> elements:
<ol>
  <li>First item</li>
  <li>Second item</li>
  <li>Third item</li>
</ol>
But in a block editor, how do you represent this structure? You have two obvious approaches, and both does not work as expected
❌ Bad Solution #1: One Block = Complete List
Make the entire list a single block that manages all its items internally. Sounds reasonable, right? For a simple, flat list, it could work just fine. But what happens when you need nested lists? Since blocks can’t have children between parent content, this structure breaks down
// This approach can't handle:
<ol>
  <li>First item</li>
  <li>Second item
    <ol>
      <li>Nested item A</li>
      <li>Nested item B</li>
    </ol>
  </li>
  <li>Third item</li>
</ol>
❌ Bad Solution #2: Each Item = Separate Block
Make each list item its own block, where every block renders its own <ol><li> pair. That works too, but it breaks the numbering completely
// Each block renders its own <ol><li> pair:
<ol><li>First item</li></ol>
<ol><li>Second item</li></ol>
<ol><li>Third item</li></ol>

// Result: Every item is numbered "1"!
// 1. First item
// 1. Second item  <- Should be "2"
// 1. Third item   <- Should be "3"
✅ The Solution: Subblocks
Subblocks solve this by creating a parent-child relationship where:
  • The parent block (e.g., ordered-list) renders the <ol> wrapper and manages the numbering context
  • The subblocks (e.g., ordered-list-item) render individual <li> elements inside their parent
This structure enables powerful behaviors:
  • Adjacent subblocks are automatically merged into the same parent block
  • Sublists work perfectly because subblocks can have their own children
  • Each subblock behaves like a regular block: it can be added, removed, or split—no extra setup needed.
You've actually seen subblocks in action already. Every time you create a list in Magicube, you're working with subblocks.
Building Your First Subblock: Radio Button Group
Let's build something practical: a radio button group. This is a good example of related blocks since selecting one should automatically deselect the others.
The beauty of subblocks is that we don't need any complex state management or hacks to make this work. Since all radio buttons live within the same parent block, they naturally share the same context.
Plugin Structure and Types
First, let's set up our plugin structure and define the subblock relationship. The key here is the subBlocks property that tells Magicube which blocks can be children:
import {
  ContentBlockPlugin,
  BlockRenderComponentProps,
  useBlock,
  useMagicube,
} from "@magicube/editor";

//...

export function makeRadioButtonPlugin(): ContentBlockPlugin<RadioGroupData> {
  return {
    goalVersion: "0.1.0",
    schema: {
      type: "radio-group",
      render: RadioGroup, // defined later
      initialData: { selectedOptionId: null },
      subBlocks: [
        {
          schema: {
            type: "radio-option",
            render: RadioOption, // defined later
          },
        },
      ],
    },
  };
}

type RadioGroupData = {
  selectedOptionId: string | null;
};
The subBlocks array defines which block types can be children of this block. Each radio-option will automatically become a child of the radio-group when created adjacently. When creating a radio-option, it will wrap inside a radio-group automatically.
Root Block Component
The root block acts as a container for our radio options. Notice how we place the attributes on the children container, not the title. It ensures that the label component stays not editable.
function RadioGroup(props: BlockRenderComponentProps<RadioGroupData>) {
  const { children, attributes } = props;

  return (
    <div className="border border-gray-200 rounded p-3 my-2">
      <div className="font-medium mb-2">Select an option</div>
      <div className="space-y-1" {...attributes}>
        {children}
      </div>
    </div>
  );
}
The children prop contains all the radio options.
Subblock Component
The subblock component uses getRootBlock() to access its parent. This is the key to subblock communication:
function RadioOption(props: BlockRenderComponentProps) {
  const { children, attributes, blockId } = props;
  const { getRootBlock } = useBlock();
  const { editor } = useMagicube();
  const rootBlock = getRootBlock();
  
  const rootData = rootBlock?.data as RadioGroupData;
  const isSelected = rootData?.selectedOptionId === blockId;

  const handleSelect = () => {
    if (isSelected) return; // Already selected
    
    const rootBlockId = rootBlock?.id;
    if (rootBlockId && blockId && editor) {
      // Update the root block's selectedOptionId to this block's ID
      editor.mutate.setBlockData(rootBlockId, { selectedOptionId: blockId });
    }
  };

  return (
    <div className="flex items-center space-x-2">
      <input
        type="radio"
        checked={isSelected}
        onChange={handleSelect}
        name={`radio-group-${rootBlock?.id || "default"}`}
      />
      <div {...attributes} className="flex-1 flex items-center">
        {children}
      </div>
    </div>
  );
}
The getRootBlock() function returns the parent radio group. We check the root's selectedOptionId against our blockId to determine if this option is selected. This ensures only one option can be selected at a time, which is proper radio button behavior.
Since we want to update the data of the root block from within a subblock, we use editor.mutate.setBlockData, passing the root block’s ID along with the new data. The editor instance comes from the useMagicube hook.
While useBlock is focused on the current block’s context—like updating its own data or marks—useMagicube gives you access to document-wide operations. It’s a powerful API that lets you modify the entire structure, and we’ll explore it in more depth later.
Editor Configuration
Finally, register your plugin in the editor configuration:
const editor = createMagicubeEditor({
  plugins: {
    blocks: [
      makeRadioButtonPlugin(),
      // ... other plugins
    ],
    marks: makeMarks(),
  },
});
That's it! The subblock relationship is automatically handled by Magicube. When you create adjacent radio-option blocks, they automatically become children of the same radio-group parent.
radio-button-subblocks
JSON Output
When you create a radio button group with three options, this is the JSON you get:
{
  "id": "radio-group-1",
  "type": "radio-group",
  "content": [],
  "children": [
    {
      "id": "option-1",
      "type": "radio-option",
      "content": [{ "text": "Option A" }],
      "children": [],
      "data": {}
    },
    {
      "id": "option-2",
      "type": "radio-option",
      "content": [{ "text": "Option B" }],
      "children": [],
      "data": {}
    },
    {
      "id": "option-3",
      "type": "radio-option",
      "content": [{ "text": "Option C" }],
      "children": [],
      "data": {}
    }
  ],
  "data": {
    "selectedOptionId": "option-1"
  }
}
In the next section, we'll explore marks—the other half of the content creation equation. While blocks handle structure, marks handle styling within a block. Together, they give you complete control over your content's appearance and behavior.