Migrate render props to node registrations
Through v7, the editor rendered a document’s content (text blocks, block objects, inline objects, spans, decorators, and annotations) through render props on <PortableTextEditable>: renderBlock, renderChild, renderStyle, renderListItem, renderDecorator, renderAnnotation. v8 removes them in favor of node registrations (defineTextBlock, defineBlockObject, defineInlineObject, defineSpan, defineContainer, defineDecorator, defineAnnotation) mounted through NodePlugin. Registrations are how containers render, and for a registered node, decorator, or annotation they owned the wrapper entirely: the matching render prop did not fire once a registration claimed that type.
This guide maps each render prop to its registration equivalent, so you can migrate before upgrading to v8. You don’t have to do it in one go.
What maps to what
Section titled “What maps to what”| Legacy | New |
|---|---|
renderStyle, renderListItem, renderBlock (for text blocks) |
defineTextBlock({type: 'block'}) |
renderBlock (for block objects) |
defineBlockObject, per type or type: '*' |
renderChild (for inline objects) |
defineInlineObject, per type or type: '*' |
renderChild (for spans) |
defineSpan({type: 'span'}) |
renderDecorator |
defineDecorator, per decorator name or type: '*' |
renderAnnotation |
defineAnnotation, per _type or type: '*' |
renderPlaceholder, range decorations |
Unchanged, keep them |
| Engine list wrapping and numbering | Your text block render + @portabletext/plugin-list-index |
| Engine drop indicator | @portabletext/plugin-dnd |
Migrate one kind at a time
Section titled “Migrate one kind at a time”The engine dispatched per _type: a node rendered through a registration when one matched its type (or a '*' catch-all for its kind), and at the top level fell back to the render prop when none did. Registering block objects didn’t affect how text blocks rendered, so you could migrate kind by kind and keep the remaining render props in place until their kind was migrated.
One boundary to plan around: a registration claimed everything rendered inside it. Inside a registered container, unregistered types rendered through the engine defaults, not through your remaining render props, so when a container entered the picture mid-migration, you had to register everything that rendered inside it in the same step. The same applied one level down: a registered text block claimed its inline children, and renderChild stopped firing for the spans and inline objects inside it. That is why the steps below migrate inline objects and spans before text blocks.
Decorators and annotations sat outside this claiming rule. The engine dispatched them per decorator and annotation inside every span’s leaf render, independent of whether the enclosing span or text block was registered, so migrating them carried no ordering dependency on the other kinds; do it whenever you like.
Step 1: block objects
Section titled “Step 1: block objects”The legacy renderBlock handles text blocks and block objects in one callback, branching on schemaType:
const renderBlock: RenderBlockFunction = (props) => { if (props.schemaType.name === 'image' && isImage(props.value)) { return ( <div style={{border: '1px solid #ccc', padding: '0.5em'}}> <img src={props.value.src} alt={props.value.alt || ''} /> </div> ) } // Default case for text blocks return <div style={{marginBlockEnd: '0.5em'}}>{props.children}</div>}The registration splits that branch into kinds. A block object registers by its type, no isImage guard needed, the registration is the type match:
import {defineBlockObject} from '@portabletext/editor'
const imageBlock = defineBlockObject({ type: 'image', render: (props) => typeof props.node.src === 'string' ? ( <div {...props.attributes} style={{border: '1px solid #ccc', padding: '0.5em'}} > <div contentEditable={false} draggable={!props.readOnly}> <img src={props.node.src} alt={String(props.node.alt ?? '')} /> </div> {props.children} </div> ) : ( props.renderDefault(props) ),})Four contract differences from the legacy callback: spread attributes onto your outer element, render children even though the block is a void (the engine mounts its internals through them), mark the visible content contentEditable={false} while keeping the outer element editable (the engine anchors the caret through it), and put draggable={!readOnly} on that non-editable wrapper. The legacy pipeline added the contentEditable and draggable wrapper for you; a registration owns the whole node, so dropping draggable silently loses drag-to-move. This mirrors the engine’s own default block-object render.
If you render every block object the same way, a preview card, say, the catch-all type: '*' matches every block object type that has no more specific registration, which is the direct equivalent of the generic legacy callback:
const blockObjectFallback = defineBlockObject({ type: '*', render: ({attributes, children, node, readOnly}) => ( <div {...attributes}> <div contentEditable={false} draggable={!readOnly}> <BlockObjectPreview node={node} /> </div> {children} </div> ),})Per-type registrations and the catch-all compose: register image specifically and '*' for the rest.
Step 2: inline objects
Section titled “Step 2: inline objects”renderChild migrates the same way. Before:
const renderChild: RenderChildFunction = (props) => { if (props.schemaType.name === 'stock-ticker' && isStockTicker(props.value)) { return <span className="ticker">{props.value.symbol}</span> } return props.children}After, per type or catch-all:
import {defineInlineObject} from '@portabletext/editor'
const stockTicker = defineInlineObject({ type: 'stock-ticker', render: (props) => typeof props.node.symbol === 'string' ? ( <span {...props.attributes} className="ticker"> {props.children} <span draggable={!props.readOnly} style={{display: 'inline-block'}}> {props.node.symbol} </span> </span> ) : ( props.renderDefault(props) ),})Unlike block objects, inline objects need no contentEditable={false}: the engine’s outer attributes already carry non-editability, and your content inherits it. They DO need the inner draggable={!readOnly} wrapper around the visible content: the legacy pipeline wrapped your renderChild output in a draggable inline-block span, which is what makes an inline object drag-movable and keeps its text unselectable (a draggable element starts a drag instead of a text selection). A registration that renders the content bare loses both behaviors, and nothing fails loudly. The legacy default case (return props.children) disappears: spans keep rendering through the engine, with decorator and annotation renders still composing inside them exactly as before, and only registered inline object types hit your render.
Step 3: spans, if you customized them
Section titled “Step 3: spans, if you customized them”Most editors never touch how spans render directly: decorators and annotations, covered in steps 5 and 6, handle the styling inside a span. But the legacy renderChild also fired for the spans themselves, and if yours wrapped them, the registration equivalent is defineSpan:
import {defineSpan} from '@portabletext/editor'
const span = defineSpan({ type: 'span', render: ({attributes, children}) => ( <span {...attributes} className="leaf"> {children} </span> ),})A registered span owns the outer wrapper, and children arrive with the decorator and annotation renders from defineDecorator/defineAnnotation already applied, the same composition the legacy pipeline produced inside the span. 'span' is the span type at the top level; text blocks can positionally register renamed span-like types through their own of (a code-span inside a code block’s line, say), which is a containers concern rather than a migration one.
Step 4: text blocks
Section titled “Step 4: text blocks”Text blocks register as type: 'block', which is the text block type at every nesting level, top level and inside containers alike. The registration’s render owns the whole wrapper, so the three legacy callbacks fold into one function. Reproduce the engine’s legacy composition order: style innermost, list item around it, block wrapper outermost.
Before:
const renderStyle: RenderStyleFunction = (props) => { if (props.schemaType.value === 'h1') { return <h1>{props.children}</h1> } if (props.schemaType.value === 'blockquote') { return <blockquote>{props.children}</blockquote> } return <>{props.children}</>}
const renderListItem: RenderListItemFunction = (props) => ( <ListItemWrapper level={props.level}>{props.children}</ListItemWrapper>)
// Plus the text-block default case of the `renderBlock` from step 1:// <div style={{marginBlockEnd: '0.5em'}}>{props.children}</div>After:
import {defineTextBlock} from '@portabletext/editor'
const textBlock = defineTextBlock({ type: 'block', render: ({attributes, children, node}) => { let content = children
// Your `renderStyle` logic, innermost. if (node.style === 'h1') { content = <h1>{content}</h1> } else if (node.style === 'blockquote') { content = <blockquote>{content}</blockquote> }
// Your `renderListItem` logic around it. if (node.listItem !== undefined) { content = ( <ListItemWrapper level={node.level ?? 1}>{content}</ListItemWrapper> ) }
// Your `renderBlock` default case, outermost. return ( <div {...attributes} style={{marginBlockEnd: '0.5em'}}> {content} </div> ) },})The render props carry node, path, focused, and selected, so components you already have keep receiving the information the legacy callbacks gave them.
Step 5: decorators
Section titled “Step 5: decorators”The legacy renderDecorator switches on the decorator name via props.value:
const renderDecorator: RenderDecoratorFunction = (props) => { if (props.value === 'strong') { return <strong>{props.children}</strong> } if (props.value === 'em') { return <em>{props.children}</em> } if (props.value === 'underline') { return <u>{props.children}</u> } return <>{props.children}</>}A per-decorator registration replaces each branch. type is the decorator name, so no props.value check is needed:
import {defineDecorator} from '@portabletext/editor'
const strong = defineDecorator({ type: 'strong', render: ({children}) => <strong>{children}</strong>,})const em = defineDecorator({ type: 'em', render: ({children}) => <em>{children}</em>,})const underline = defineDecorator({ type: 'underline', render: ({children}) => <u>{children}</u>,})If you’d rather keep one switching function, type: '*' matches every decorator that has no more specific registration and hands you the decorator name as decorator where the legacy callback gave you value:
const decoratorFallback = defineDecorator({ type: '*', render: ({children, decorator}) => { if (decorator === 'strong') { return <strong>{children}</strong> } if (decorator === 'em') { return <em>{children}</em> } if (decorator === 'underline') { return <u>{children}</u> } return <>{children}</> },})Per-decorator registrations and the catch-all compose: register strong specifically and '*' for the rest.
Step 6: annotations
Section titled “Step 6: annotations”Before:
const renderAnnotation: RenderAnnotationFunction = (props) => { if (props.schemaType.name === 'link') { return <span style={{textDecoration: 'underline'}}>{props.children}</span> } return <>{props.children}</>}After, per _type. The registration receives the annotation’s markDef object as annotation, in place of the legacy schemaType/value pair:
import {defineAnnotation} from '@portabletext/editor'
const link = defineAnnotation({ type: 'link', render: ({children}) => ( <span style={{textDecoration: 'underline'}}>{children}</span> ),})Or, matching the legacy switching style, type: '*' and discriminate on annotation._type:
const annotationFallback = defineAnnotation({ type: '*', render: ({annotation, children}) => annotation._type === 'link' ? ( <span style={{textDecoration: 'underline'}}>{children}</span> ) : ( <>{children}</> ),})annotation is the full markDef object ({_key, _type, ...fields}) with an unknown index signature, so a field the legacy callback read off a separately-typed value (a link annotation’s href, say) needs a cast: String(annotation.href). It is named annotation, not node, because path addresses the span carrying the annotation, not the markDef.
Step 7: reclaim the engine’s chrome
Section titled “Step 7: reclaim the engine’s chrome”Under the legacy pipeline the engine wrapped list items and numbered ordered lists for you, and drew a drop indicator during block drags. Under registrations both are yours. @portabletext/plugin-list-index computes the 1-based list index at any path, and @portabletext/plugin-dnd tracks the drop position from the editor’s drag.* events. Both follow the same pattern, a provider inside EditorProvider and a hook read from a component your render returns; the Rendering page covers the pattern, and each plugin’s README carries the full recipe, including a reference DropIndicator implementation.
Step 8: mount and clean up
Section titled “Step 8: mount and clean up”Mount every registration through one NodePlugin with a stable identity, and drop the migrated render props:
import {NodePlugin} from '@portabletext/editor/plugins'
// Plus `span` from step 3, if you needed it.const nodes = [ imageBlock, blockObjectFallback, stockTicker, textBlock, strong, em, underline, link,]
function App() { return ( <EditorProvider initialConfig={{schemaDefinition}}> <NodePlugin nodes={nodes} /> <PortableTextEditable // This keeps working and stays: renderPlaceholder={renderPlaceholder} // renderBlock, renderChild, renderStyle, renderListItem, // renderDecorator, renderAnnotation: removed /> </EditorProvider> )}renderPlaceholder is the only render prop left unmigrated: registrations render nodes identified by a schema type, and the placeholder is empty-editor chrome with no type to register under, so it stays as is. rangeDecorations is a separate data prop, not a render prop: it carries a component that wraps a span’s rendered output from the outside, after the span (registered or not) has already rendered, so it is untouched by this migration too.