useBlocks(): read and mutate blocks from React
A reactive snapshot of the block tree plus a full manipulation API, from the framework adapters. Use the useBlocks(editor, options?) hook in @bloklabs/react, the useBlocks(editor, options?) composable in @bloklabs/vue, or injectBlocks(editor, options?) in @bloklabs/angular. In Angular, pass the instance signal of BlokEditorComponent/BlokContentDirective, and call it from an injection context (a field initializer or the constructor).
Reads re-render reactively as the document changes. Writers are atomic: one undo step. They are also safe to call before the editor is ready, where they no-op.
Returned BlockNode objects ({ id, type, parentId, contentIds }) come from a fresh snapshot and go stale. Read them now, don't stash them in dep arrays.
Reactivity is document-wide by default. Pass { within: blockId } to re-render only for changes inside that block's subtree: the block itself or any descendant.
Reach for it in a container block that renders only its own children. Unscoped, such a block re-renders on every keystroke anywhere in the document, and a page of N containers turns one keystroke into N re-renders.
The scope bounds re-renders, not reads: a scoped handle still sees the whole tree, so getById/getChildren keep working on anything. In Vue the scope also accepts a ref or getter, and in Angular a signal. It is read at emit time, so changing it needs no re-subscription.
Methods
getById(id)
BlockNode | nullThe block with the given id as a snapshot node, or null when unknown.
const node = blocks.getById('x9k2f1');
// → { id: 'x9k2f1', type: 'paragraph', parentId: null, contentIds: [] }getChildren(parentId)
BlockNode[]The direct children of a parent block, in document order. Pass null for the root blocks.
const rootBlocks = blocks.getChildren(null);
const rowBlocks = blocks.getChildren(databaseBlockId);insert(spec?)
BlockNode | nullInsert one block (type, data, parentId, position, tunes, id, focus/caret, replace). Use replace: true with a position that targets an existing block to replace that block instead of inserting beside it. That is a programmatic "turn into".
Returns the created node, or null when rejected:
- an unknown tool type
- a dangling parentId
- a
replacewhose target is missing
An explicit id that already exists is insert-if-absent. The call is atomic: one undo step.
const node = blocks.insert({
type: 'header',
data: { text: 'New section', level: 2 },
position: 'end',
focus: true,
});
// → node.id is the new block's id (or null if rejected)insertMany(specs)
BlockNode[]Insert several blocks atomically, in array order, as ONE undo step. Specs that fail are dropped. Returns the nodes that were created.
const nodes = blocks.insertMany([
{ type: 'header', data: { text: 'Title' } },
{ type: 'paragraph', data: { text: 'Body' } },
]);insertTree(spec)
BlockNode | nullInsert a pre-built NESTED subtree in one atomic operation. Children are inserted under their enclosing node recursively. Placement options apply to the root only. Returns the root node, or null on a rejected or colliding id.
const root = blocks.insertTree({
type: 'toggle',
data: { text: 'Details' },
children: [
{ type: 'paragraph', data: { text: 'Hidden content' } },
],
});insertMarkdown(markdown, options?)
Promise<BlockNode[]>Convert a Markdown string to blocks and insert them ADDITIVELY, without clearing the document. The call is async, because the converter is lazy-loaded. Returns all created nodes in document order. Empty input or a dangling parentId returns [].
const nodes = await blocks.insertMarkdown(
'# Title\n\n- one\n- two',
{ position: 'end' },
);exportMarkdown()
Promise<string>Serialize the WHOLE document to Markdown. The call is async and the serializer is lazy-loaded. Structure Markdown can't express, such as merged table cells, is dropped.
const md = await blocks.exportMarkdown();markdownToBlocks(md, config?)
Promise<OutputBlockData[]>Convert Markdown to blocks WITHOUT an editor instance. It is the standalone @bloklabs/core/markdown subpath, not a method on the hook.
It needs no DOM and no mounted Blok, so it covers the server-side path that insertMarkdown/exportMarkdown cannot:
- import Markdown in a Node job
- seed a document
- precompute
databefore the editor mounts
config is a MarkdownImportConfig (tool mapping, GFM, extensions). The result is ready for blocks.render() or blocks.insertMany().
import { markdownToBlocks } from '@bloklabs/core/markdown';
// No editor instance required — this also runs on the server
const parsed = await markdownToBlocks('# Title\n\n- one\n- two');
// -> OutputBlockData[]; store it, or hand it to a live editor
await blocks.render({ blocks: parsed });markdownToBlocksWithReport(md, config?)
Promise<{ blocks: OutputBlockData[]; warnings: MarkdownDegradation[] }>The same blocks, plus what Markdown could not carry into them. Blok has no raw-HTML block, so markup written into the Markdown is escaped and stored as literal text. The one exception is a bare <br>, which becomes a line break. The escaping is safe, but silent.
Use this when the import is unattended (an MCP tool, an agent, a bulk migration) and something has to be told what changed. Its outbound twin is blocksToMarkdownWithReport in @bloklabs/core/view.
import { markdownToBlocksWithReport } from '@bloklabs/core/markdown';
const { blocks: parsed, warnings } = await markdownToBlocksWithReport(source);
// warnings: [{ construct: 'html', action: 'degraded', detail: 'HTML is escaped and stored as literal text…' }]
if (warnings.length === 0) {
await blocks.render({ blocks: parsed });
}move(id, target)
voidMove a block to a flat slot: { before }, { after }, or { toIndex }. The block adopts the parent of wherever it lands. Use nest/unnest to change the parent without picking a sibling slot.
blocks.move(nodeId, { after: otherId });
blocks.move(nodeId, { toIndex: 0 });nest(id, parentId)
voidMake a block a child of another block.
blocks.nest(childId, toggleId);unnest(id)
voidMove a nested block up one level (out of its parent).
blocks.unnest(childId);remove(id)
voidRemove a block (and its subtree).
blocks.remove(nodeId);update(id, data?, tunes?)
voidUpdate a block's data and/or tunes by id. It delegates to core's async blocks.update, which is its own undo step. Unknown ids are a silent no-op.
blocks.update(nodeId, { text: 'Edited' });convert(id, newType, dataOverrides?, options?)
voidConvert a block to another type ("turn into"). Both tools must define conversionConfig. A block that cannot be converted is a graceful no-op. options.caret places the caret in the converted block.
blocks.convert(nodeId, 'header', { level: 2 });transact(fn)
voidRun several mutations as ONE atomic undo step.
blocks.transact(() => {
blocks.remove(oldId);
blocks.insert({ type: 'paragraph', data: { text: 'Replacement' } });
});transactWithoutCapture(fn, options?)
voidLike transact, but the operation is NOT captured in undo history. Use it for silent auto-repair or normalization that CMD+Z should never step through.
Pass { derivedFrom: blockId, from: ['url'] } when the operations are data worked out from those keys of a block, as with core's blocks.transactWithoutCapture. They join the undo step that wrote those values.
blocks.transactWithoutCapture(() => {
blocks.update(nodeId, { text: normalized });
});splitBlock(currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex)
BlockNode | nullAtomically split a block: update the current block and insert a new one at an absolute flat index, as ONE undo step.
const newNode = blocks.splitBlock(
nodeId, { text: 'First half' },
'paragraph', { text: 'Second half' },
blocks.getBlockIndex(nodeId)! + 1,
);insertInsideParent(parentId, insertIndex, childData?)
BlockNode | nullInsert a single child under a parent at a flat index, atomically: creation AND parent assignment in ONE undo step. Prefer it over insert() + nest(), which takes two steps.
The index is clamped into the parent's subtree. The child never lands before its parent or past the end of the parent's subtree.
const child = blocks.insertInsideParent(toggleId, 3);insertOutputData(blocks, options?)
BlockNode[]Insert a flat array of already-serialized OutputBlockData (the save() shape) directly, honoring parent/content links. One atomic undo step.
const nodes = blocks.insertOutputData(savedFragment.blocks);render(data)
Promise<void>Replace the WHOLE document with blocks from saved OutputData. It is a document-LOAD primitive: it clears existing content first, unlike the additive inserters.
await blocks.render(savedData);renderFromHTML(html)
Promise<void>Replace the WHOLE document with blocks parsed from an HTML string (clears existing content first).
await blocks.renderFromHTML('<h1>Imported</h1><p>Body</p>');clear()
Promise<void>Remove every block from the document.
await blocks.clear();getBlocksCount()
numberThe current block count (reactive).
const count = blocks.getBlocksCount();getCurrentBlockIndex()
numberThe flat index of the block holding the caret, or -1 when none.
const index = blocks.getCurrentBlockIndex();getBlockByIndex(index)
BlockNode | nullThe block at a flat index as a snapshot node, or null.
const first = blocks.getBlockByIndex(0);getBlockIndex(id)
number | nullThe absolute flat index of a block by id, or null when unknown.
const index = blocks.getBlockIndex(nodeId);getBlockData(id)
{ data, tunes } | nullRead a block's current data and tunes by id without mutating anything. It makes a client-side duplicate composable: read a node, then insert({ type, data, tunes }).
const saved = blocks.getBlockData(nodeId);
if (saved) {
blocks.insert({ type: 'paragraph', data: saved.data, position: { after: nodeId } });
}getBlockByElement(element)
BlockNode | nullThe block whose holder contains or equals a DOM element. It maps an event target back to a block.
const node = blocks.getBlockByElement(event.target as HTMLElement);composeBlockData(toolName)
Promise<BlockToolData>Read a tool's default empty data without inserting anything. Rejects for an unknown tool.
const defaults = await blocks.composeBlockData('header');isSyncingFromYjs()
booleanWhether a Yjs sync (undo/redo) is in progress. Use it to skip cleanup that would fight undo state.
if (!blocks.isSyncingFromYjs()) {
blocks.update(nodeId, { text: cleaned });
}import { useBlok, BlokContent, useBlocks } from '@bloklabs/react';
export function Outline() {
const editor = useBlok({ tools });
const blocks = useBlocks(editor);
// Reactive: re-renders whenever the document changes.
const rootBlocks = blocks.getChildren(null);
return (
<>
<BlokContent editor={editor} />
<ol>{rootBlocks.map((b) => <li key={b.id}>{b.type}</li>)}</ol>
</>
);
}
// Inside a container block: re-render only for its OWN subtree, so typing in an
// unrelated block elsewhere in the document costs this component nothing.
function Steps({ block }) {
const blocks = useBlocks(useBlokInstance(), { within: block.id });
const steps = blocks.getChildren(block.id);
return <ol>{steps.map((s) => <li key={s.id}>{s.type}</li>)}</ol>;
}