WordCamp US 2025: Block Composability – Templating

Let’s say you need to template a block. This means you insert a block and configure its styles and settings. Then, you repeat that block X times for Y data on the frontend. Well, there’s a couple of routes you can take to achieve this. Lets take a look at a few:

  • Using <BlockContextProvider/> to provide high fidelity templating in the editor.
  • Rendering blocks manually for each templated item.

<BlockContextProvider/>

Block Context Provider is a nifty little component, part of `@wordpress/block-editor`. With it, you can inject new block context values for inner blocks. This feature is very useful. It allows you to display dynamic data in the editor. You do not need to actually set an attribute value.

Lets take a look at a quick code example:

<div {...blockProps}>
<BlockContextProvider
    key={`my-context-provider-${clientId}`}
    value={{
        'my-block/special-value': 'Hello World'
    }}
>
    <div {...innerBlocksProps} />
</BlockContextProvider>
</div>

In this example, any block placed inside will have available to them `my-block/special-value`.

You can imagine a custom block can use this directly, by adding `my-block/special-value` to `usesContext` in `block.json`

OR you can imagine a `core` block utilizing a custom binding that uses the `my-block/special-value` context value.

Either way, blocks inside this example can consume `context[‘my-block/special-value’]`.

Going further, you can use BlockContextProvider to iterate over dynamic data in the editor to show a list of items. As a rough example:

const blockContexts = [
    {
        'my-block/special-value': 'Hello World',
    },
    {
        'my-block/special-value': 'Hello World 2!',
    },
    {
        'my-block/special-value': 'Hello World 3!',
    }
];
<div {...blockProps}>
{blockContexts.map((blockContext, index) => {
    return (
        <BlockContextProvider
            key={`context-key--${index}`}
            value={blockContext}
        >
            <div {...innerBlocksProps} />
        </BlockContextProvider>
    );
})}
</div>

This example is missing some additional logic. It needs logic to handle recursions. It also needs logic to ensure only one block is interactable at any given time. For that you’ll need more robust React function, like the one below…

<InnerBlocksAsContextTemplate/> and getInnerBlocksContextAsQuery

This is a component pulled directly form our @prc/components library.

/**
 * External Dependencies
 */
import md5 from 'md5';
/**
 * WordPress Dependencies
 */
import {
	memo,
	useMemo,
	useState,
	useEffect,
	Fragment,
} from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import {
	BlockContextProvider,
	__experimentalUseBlockPreview as useBlockPreview,
	useInnerBlocksProps,
	store as blockEditorStore,
	Warning,
} from '@wordpress/block-editor';
import { Spinner, Flex, FlexBlock, FlexItem } from '@wordpress/components';
import { useEntityRecords } from '@wordpress/core-data';
function InnerBlocksTemplateBlocks({
	allowedBlocks = [
		'core/post-title',
		'core/post-date',
		'core/post-excerpt',
		'core/group',
		'core/paragraph',
	],
	template,
	wrapperProps = {},
}) {
	const innerBlocksProps = useInnerBlocksProps(
		wrapperProps,
		{
			allowedBlocks,
			template,
		}
	);
	return <div {...innerBlocksProps} />;
}
function InnerBlocksAsContextTemplatePreview({
	blocks,
	blockContextId,
	isHidden,
	setActiveBlockContextId,
}) {
	const blockPreviewProps = useBlockPreview({ blocks });
	// When clicking into the preview, set the active block context as whichever block you click on.
	const handleOnClick = () => {
		setActiveBlockContextId(blockContextId);
	};
	// Hide the preview when it is not the active block context.
	const style = {
		display: isHidden ? 'none' : undefined,
	};
	return (
		<div
			{...blockPreviewProps}
			tabIndex={0}
			role="button"
			onClick={handleOnClick} // When clicking into a block preview this keeps the block active.
			onKeyDown={handleOnClick} // Ensures any keyboard event will keep this block active.
			style={style}
		/>
	);
}
const MemoziedInnerBlocksTemplatePreview = memo(
	InnerBlocksAsContextTemplatePreview
);
export function getInnerBlocksContextAsQuery(postType, perPage = 10) {
	const { records, isResolving } = useEntityRecords('postType', postType, {
		per_page: perPage,
		post_parent: 0, // exclude child posts
		context: 'view',
	});
	/**
	 * Constructs a context of blocks for each post.
	 */
	const blockContexts = useMemo(() => {
		if (!records || isResolving) {
			return [];
		}
		return records?.map((post) => {
			return {
				queryId: 0,
				postId: post.id,
				postType: post.type,
				props: post.props,
			};
		});
	}, [records, isResolving]);
	return { blockContexts, isResolving };
}
export function InnerBlocksAsContextTemplate({
	clientId,
	allowedBlocks,
	template,
	blockContexts,
	isResolving = true,
	loadingLabel = 'Loading...',
	wrapperProps = {},
}) {
	const [activeBlockContextId, setActiveBlockContextId] = useState(null);
	const { blocks } = useSelect(
		(select) => {
			const { getBlocks } = select(blockEditorStore);
			return {
				blocks: getBlocks(clientId),
			};
		},
		[clientId]
	);
	useEffect(() => {
		if (blockContexts.length > 0) {
			// Set the first block as active by default.
			const firstBlockContext = blockContexts[0];
			setActiveBlockContextId(md5(JSON.stringify(firstBlockContext)));
		}
	}, [blockContexts]);
	if (isResolving) {
		return (
			<Warning>
				<Flex align="center" gap="10px">
					<FlexBlock>{`${loadingLabel}`}</FlexBlock>
					<FlexItem>
						<Spinner />
					</FlexItem>
				</Flex>
			</Warning>
		);
	}
	// To avoid flicker when switching active block contexts, a preview is rendered
	// for each block context, but the preview for the active block context is hidden.
	// This ensures that when it is displayed again, the cached rendering of the
	// block preview is used, instead of having to re-render the preview from scratch.
	return (
		<div {...wrapperProps}>
			{blockContexts &&
			blockContexts.map((blockContext, index) => {
				const contextId = md5(JSON.stringify(blockContext));
				const isVisible =
					contextId ===
					(activeBlockContextId || md5(JSON.stringify(blockContexts[0])));
				return (
					<BlockContextProvider
						key={`context-key--${index}`}
						value={blockContext}
					>
						{activeBlockContextId === null || isVisible ? (
							<InnerBlocksTemplateBlocks
								{...{
									allowedBlocks,
									template,
								}}
							/>
						) : null}
						<MemoziedInnerBlocksTemplatePreview
							blocks={blocks}
							blockContextId={contextId}
							setActiveBlockContextId={setActiveBlockContextId}
							isHidden={isVisible}
						/>
					</BlockContextProvider>
				);
			})}
		</div>
	);
}

This leaves you with two very powerful functions that are easy to use. These functions allow you to iterate over Block Context to build templates. They also utilize WP post type objects as individual block context items.

const blockProps = useBlockProps({
	style: {
		gap: getBlockGapSupportValue(attributes),
	},
});
const { blockContexts, isResolving } = getInnerBlocksContextAsQuery(
	'post',
	perPage
);
return (
	<div {...blockProps}>
		<InnerBlocksAsContextTemplate
			{...{
				clientId,
				allowedBlocks: [
					'core/post-title',
					'core/post-date',
					'core/post-excerpt',
				],
				template: [
					['core/post-title'],
					['core/post-date'],
					['core/post-excerpt'],
				],
				blockContexts,
				isResolving,
			}}
		/>
	</div>
);

Rendering on the frontend

There are many strategies you can employ to iterate over templates on the frontend. You can use the Interactivity API <template/> directives. Another option is to render the new blocks manually. Lets look at rendering blocks manually approach:

function render_block_callback( $attributes, $content, $block ) {
	$items = array(
		array(
			'content' => 'Item 1',
		),
		array(
			'content' => 'Item 2',
		),
		array(
			'content' => 'Item 3',
		),
	)
	$block_attrs = get_block_wrapper_attributes();
	$block_content = '';
	$block_instance = $block->parsed_block;
	// Set the block name to one that does not correspond to an existing registered block.
	// This ensures that for the inner instances of the this block, we do not render any block supports.
	$block_instance['blockName'] = 'core/null';
	foreach ( $items as $item ) {
		// Render the inner blocks of this block with `dynamic` set to `false` to prevent calling
		// `render_callback` and ensure that no wrapper markup is included.
		$block_content .= (
			new WP_Block(
				$block_instance,
				$item // The 2nd argument is context for innerblocks.
			)
		)->render( array( 'dynamic' => false ) );
	}
	return wp_sprintf(
		'<div %1$s>%2$s</div>',
		$block_attrs,
		$block_content
	);
}