Block patterns, and the page an editor can build

The plugin had eleven custom blocks. Nine of them were layouts — a two-column call to action, a three-card feature row, a testimonial with an image — and each was a React component, a build step and a serialisation contract, in order to produce markup an editor could have assembled from core blocks if anybody had shown them how.

The symptom

$ ls src/blocks/
cta-two-column/  feature-row/  testimonial/  team-grid/
stat-band/  logo-wall/  quote-image/  pricing-table/
faq-accordion/  notice/  product-picker/

$ wc -l src/blocks/*/index.js | tail -1
  2841 total
$ du -sh build/
412K	build/

# and what nine of them produce: core/columns wrapping
# core/heading, core/paragraph and core/button.

Two thousand eight hundred lines of JavaScript, a webpack configuration and a 412-kilobyte bundle loaded in the editor, to produce arrangements of blocks that already exist. Only two of the eleven — the notice and the product picker — had behaviour that justified being blocks.

Why it happens

A block was the only unit of reuse the editor had. An editor who needs the same layout on twenty pages either rebuilds it twenty times or asks for a block, and a developer asked for a reusable layout builds the only thing available.

The consequence is a plugin where most of the code exists to work around a missing feature, and every one of those blocks carries the full cost of being a block: a save function that is a contract with every existing post, a deprecation array when the markup changes, and a build.

The fix

A pattern is markup, registered in PHP

add_action( 'init', function () {
    register_block_pattern( 'turkerdev/cta-two-column', array(
        'title'       => __( 'Two-column call to action', 'turkerdev' ),
        'categories'  => array( 'turkerdev' ),
        'keywords'    => array( 'cta', 'columns', 'button' ),
        'description' => _x( 'A heading and text beside a button.',
            'Block pattern description', 'turkerdev' ),
        'content'     => turkerdev_pattern_markup( 'cta-two-column' ),
    ) );
} );

The content is block markup — the same HTML-comment format the editor writes into post_content — so it needs no JavaScript at all. That is the entire mechanism and it is why a pattern has none of the costs a block has.

function turkerdev_pattern_markup( string $name ): string {
    return (string) file_get_contents(
        plugin_dir_path( __FILE__ ) . "patterns/{$name}.html"
    );
}

Keeping the markup in a file rather than in a PHP string is what makes it maintainable. A pattern is thirty lines of block markup with escaped quotes, and embedding that in a PHP heredoc produces something nobody will edit twice — the file version can be opened, pasted into the editor, adjusted and pasted back.

# the workflow that produced them
#   1. build the layout in the editor
#   2. select all, then Options → Copy all blocks
#   3. paste into patterns/cta-two-column.html
#   4. replace any real content with placeholders

Categories, and an inserter editors can navigate

add_action( 'init', function () {
    register_block_pattern_category( 'turkerdev', array(
        'label' => __( 'Site layouts', 'turkerdev' ),
    ) );
}, 9 );   // BEFORE the patterns that reference it

// and removing the ones core ships, which use styles this
// theme does not support
add_action( 'after_setup_theme', function () {
    remove_theme_support( 'core-block-patterns' );
} );

The priority is load-bearing: a pattern registered before its category silently lands in uncategorized, which is a tab an editor will not think to open. Registering the category on an earlier priority is the smallest fix and is easy to get wrong once and never notice.

Removing the core patterns is worth considering on a site with a strict design. They are generic, they use core colour and spacing presets the theme may not define, and an editor cannot tell which patterns belong to the site and which came with WordPress — so nine useful ones sit among forty that will never be used.

Locking, and how much freedom to give

<!-- wp:columns {"templateLock":"all"} -->
<div class="wp-block-columns">
  <!-- wp:column {"width":"60%"} -->
  <div class="wp-block-column" style="flex-basis:60%">
    <!-- wp:heading --><h2>Your heading</h2><!-- /wp:heading -->
  </div>
  <!-- /wp:column -->
</div>
<!-- /wp:columns -->

templateLock on the container is inherited by what it contains, so a pattern can be inserted and then not restructured — the text is editable and the columns cannot be reordered or removed. That is usually the right amount of freedom for a marketing layout and is more restrictive than most editors will tolerate for a content page.

The trade is that a locked pattern is a pattern somebody will ask you to unlock, and the request will arrive at the worst moment. Locking the structure and leaving the content free is the version that survives; locking everything produces a support request within a fortnight.

What is still a block

a pattern    an arrangement of existing blocks; static markup
             with editable text; a starting point to adapt

a block      behaviour — a picker, a filter, a fetch; a render
             callback; attributes that are not text; a need to
             update everywhere at once (→ reusable block)

of eleven: nine patterns, two blocks.

The product picker stays a block because it queries the catalogue and stores an id, which is a control rather than markup. The notice stays because it has a tone attribute driving a class and a dismissal behaviour — and even that is borderline, since a block style variation would cover the tone.

The distinction that decides it is whether an editor makes a choice the markup cannot express. Everything else is a pattern, and the nine that converted lost nothing an editor could observe.

Copied, not referenced, which is the trade

pattern          copied on insert. edits are per post. changing
                 the registration affects only NEW uses.

reusable block   referenced — a wp_block post. editing it changes
                 every post using it, and DELETING it silently
                 empties every one.

template         applied to NEW posts of a type. not retroactive.

The copy semantics are what make patterns safe to change — a registration that changes cannot break existing content, which is the exact opposite of a block’s save output and is the main reason the conversion is low-risk. It is also the limitation: a design change to a pattern does not reach the forty pages already using it.

Explaining that distinction to an editor once, with the three options named, saves several confused requests later. The one that surprises people is that deleting a reusable block empties every post referencing it, with no warning and no reference count anywhere in the interface.

Verifying it worked

$ ls src/blocks/
notice/  product-picker/

$ wc -l src/blocks/*/index.js | tail -1
  412 total          # was 2,841
$ du -sh build/
68K	build/             # was 412K

# and the test that matters, which is not a command:
# an editor building the campaign page unassisted, in nine
# minutes, having previously filed a ticket for every one.

The editor building a page without help is the whole outcome and it is not measurable in the pipeline. The bundle size and the line count are proxies; the ticket that did not get filed is the result.

The existing pages using the nine removed blocks had to be migrated, and that is the part that is not free — a block that no longer registers renders as a validation error. Converting them with WP-CLI, replacing the block markup with the pattern markup post by post, is a script and a careful afternoon.

What this costs

Content that is copied rather than referenced, which means a design change to a pattern reaches nothing that already exists. Forty pages using the old two-column call to action keep the old markup forever unless somebody rewrites them, and there is no report anywhere of which pages use which pattern — the pattern name is not recorded in the post at all.

That last point is worth being explicit about because it surprises people: once inserted, a pattern leaves no trace. Finding every page using one means searching the post content for a distinctive class, which works and is exactly the sort of thing that stops working when the markup changes. For anything that genuinely must be updated centrally, a reusable block or a block with a render callback is the correct tool and the extra cost is the point.