Higher-order Server-Side Rendered WordPress Blocks

Higher-order functions and higher-order (React) components are well-known concepts, but the term "higher-order block" might not be so familiar.

In this article, a higher-order block refers to a parent block that wraps another block, modifying its nested block's functionality in a meaningful way, beyond just visual changes.

Examples used in this article

Let's use a Cards Grid and an Orderby Condition (higher-order) block as examples.

The Cards Grid block, when inserted, lists six posts from newest to oldest in a 3x2 grid.

The Orderby Condition block renders its inner blocks and provides a control for changing the ordering (title, date, etc.).

When the Cards Grid is a child of the Orderby Condition block, it lists the articles based on the selected option rather than the default.

The purpose of higher-order blocks

Certainly, the described Cards Grid could simply include the control for the ordering, but let's not dwell on the chosen example.

It's easy to imagine how the controls of a block could exponentially increase if new features are introduced. To avoid confusing users, controls might be displayed conditionally, resulting in complex logic. Logic that is hard to maintain.

Higher-order blocks aim to solve this problem by encapsulating the functionality that changes a block. Depending on the higher-order block, the functionality of the child block could vary.

This solution, in our example, allows the Cards Grid to remain relatively untouched by changes, while providing the freedom to introduce new higher-order blocks (new features) and phase out others that are no longer needed.

The underlying technical solution

The Block Editor (Gutenberg) already provides a way for sharing data between ancestor and descendant blocks.

Block context is a feature which enables ancestor blocks to provide values which can be consumed by descendent blocks within its own hierarchy.

The documentation goes on to say:

Those descendent blocks can inherit these values without resorting to hard-coded values and without an explicit awareness of the block which provides those values.

With the Block Context, we can store the selected value of the orderby at the parent level (Orderby Condition block) and pass it down, making it available to the child (Cards Grid).

There's one caveat, though: blocks using server-side rendering do not have access to the context. This might change in the future; there's a ticket about this.

The gist of the implementation

The important and relevant parts for the Orderby Condition block.

{
    "name": "acme/orderby-condition",
    "attributes": {
        "orderby": {
            "type": "string"
        }
    },
    "providesContext": {
        "acme/hob-orderby": "orderby"
    },
}
const Edit = (props) => {
    // ...
    const blockProps = useBlockProps();
    const { children } = useInnerBlocksProps(blockProps, {
        allowedBlocks: ALLOWED_BLOCKS,
    });

    return (
        <>
            // ...
            <div {...blockProps}>{children}</div>
        </>
    );
};

const Save = () => <InnerBlocks.Content />;

Let's use a server-side rendered block for Cards Grid and find a solution for the "missing context".

{
    "name": "acme/card-grid",
    "usesContext": ["acme/hob-orderby"]
}
const Edit = (props) => {
    // ...

    return (
        <>
            // ..
            <ServerSideRender
                block={props.name}
                attributes={props.attributes}
            />
        </>
    );
};

Overcoming the missing context

context is not a valid prop of the ServerSideRender component, so we can't pass it.

If we want to pass the consumed context as an "extra" attribute, it will be removed. Unknown, undefined attributes are discarded "thanks" to __experimentalSanitizeBlockAttributes.

<ServerSideRender
    block={props.name}
    attributes={{ orderby: 'this-is-removed-as-it-is-not-registered' }}
/>

Adding the higher-order attributes manually to the inner block(s) would be unfortunate, as we would increase the coupling between the blocks more than necessary.

With the introduction of more blocks and more controls, things would become bloated; we would have to add more attributes in even more places. That was what we wanted to avoid in the first place.

The good news is, we already have a connection between the blocks: the context. We can use that information to "fill in the gaps".

Using a filter to do that

Using the block_type_metadata filter, a bit of strictness (and hackiness), we could make the attributes available dynamically.

add_filter(
    'block_type_metadata',
    static function (array $metadata): array {
        // Don't make changes to blocks that are not ours
        if (!str_starts_with($metadata['name'], 'acme/')) {
            return $metadata;
        }

        // We only care about applying higher-order block attributes
        $hobContexts = array_filter(
            $metadata['usesContext'] ?? [],
            fn(string $contextKey) => str_starts_with($contextKey, 'acme/hob-')
        );

        foreach ($hobContexts as $contextKey) {
            $attribute = str_replace('acme/hob-', '', $contextKey);

            $metadata['attributes'][$attribute] = [];
        }

        return $metadata;
    }
);

The convention here is the following: context keys prefixed with hob- are turned into "fake" attributes.

// The input
$metadata = [
    'usesContext' => 'acme/hob-orderby',
];

// The output
$metadata = [
    'usesContext' => 'acme/hob-orderby',
    'attributes' => [
        'orderby' => [],
    ],
];

Thanks to this, the attributes won't be stripped if they are passed down:

const Edit = (props) => {
    // ...

    return (
        <>
            // ..
            <ServerSideRender
                block={props.name}
                attributes={{
                    ...props.attributes,
                    // We can create a helper to make this mapping similar to the PHP
                    orderby: props.context['acme/hob-orderby'],
                }}
            />
        </>
    );
};

Then inside the render_callback, we can get access to it:

register_block_type_from_metadata(
    'block.json',
    [
        'render_callback' => function(array $attributes, string $content, WP_Block $block): string {
            // The args building can be extracted to a dedicated builder class when using OOP
            $posts = get_posts([
                'orderby' => $attributes['orderby'] ?? $block->context['acme/hob-orderby'] ?? 'date',
            ]);

            // ...
            
            return $output;
        }
    ]
)

As a summary

With this approach, we can maintain a separation between the higher-order block and the inner block while still allowing the higher-order block to modify the inner block's behavior.

This approach might ensure a more modular and maintainable structure for our blocks, which is increasingly important as new features are added.