As Gutenberg is starting to experiment with several approaches to render interactive blocks on the front end, for example using React, we wanted to share how this is currently done in WooCommerce Blocks. Our approach is not the only way of doing it, and it will probably evolve once APIs with the same purpose are added upstream to Gutenberg, however, it’s a system that proved to be solid and is giving good results in real life stores.
The gist of it
- We build our block as a React component that isn’t aware of Gutenberg. That block accepts a
prop
that is used to pass block attributes. - That React component is rendered in the editor, with the addition of some controls to set the block attributes.
- When the post or page is saved, only an empty
div
element with an ID or class is saved. Below is an example:
<!-- wp:woocommerce/product-title --> <div class="wp-block-woocommerce-product-title is-loading"></div> <!-- /wp:woocommerce/product-title -->
- In the PHP
render_callback
, we append block attributes asdata-
HTML attributes. So this is what ends up being rendered in the page:
<div data-block-name="woocommerce/product-title" class="is-loading wp-block-woocommerce-product-title"></div>
- On the front end, we just need to render the React component as you would render any other React app:
render(BlockComponent, divElement)
. We readdata-
HTML attributes and pass them asprops
to the component.
Now going into details…
File structure
We’re going to take the structure that comes with @wordpress/create-block
and add a few things to it.
We created two new files, block.js
and frontend.js
. One will contain our shared component, and the other will render it on the front end.
// ./src/block.js // Block is just a simple example of how to use attributes. export default function Block( { color, headingSize = 1 } ) { const HeadingTag = `h${ headingSize }`; return ( <div className="wc-interactive-block" style={ { color } }> <HeadingTag>Your Magic here</HeadingTag> </div> ); }
We change our edit.js
function to be like this:
// ./src/edit.js // Edit can have InspectorControl and all other stuff, no limits. export default function Edit( { attributes, setAttributes } ) { const { headingSize } = attributes; return ( <div { ...useBlockProps() }> <BlockControls> <HeadingToolbar isCollapsed={ true } minLevel={ 1 } maxLevel={ 7 } selectedLevel={ headingSize } onChange={ ( newLevel ) => setAttributes( { headingSize: newLevel } ) } /> </BlockControls> <Block { ...attributes } /> </div> ); }
And the save.js
function to this:
export default function save() { return <div { ...useBlockProps.save() } />; }
We do this because we can’t save a React component to HTML. We’re just saving a div
element that we will use as the root element later. We avoid saving attributes there as data because they will get stripped on saving if a non-admin saved the page.
Our frontend.js
would be like this:
// ./src/frontend.js import { render, Suspense } from '@wordpress/element'; import Block from './block'; window.addEventListener( 'DOMContentLoaded', () => { const element = document.querySelector( '.wp-block-create-block-interactive-block' ); if ( element ) { const attributes = { ...element.dataset }; render( <Suspense fallback={ <div className="wp-block-placeholder" /> }> <Block { ...attributes } /> </Suspense>, element ); } } );
In the code above, we’re:
- Getting the
div
we saved toelement
. - Taking the attributes from it (that we will append in a moment using PHP).
- Rendering the block using
render
.
Our Webpack configuration will change as well. We need to create a new webpack.config.js
at the project root with this:
// ./webpack.config.js const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); module.exports = { ...defaultConfig, entry: { ...defaultConfig.entry, frontend: './src/frontend.js', }, };
Running npm run build
will produce our files!
PHP changes needed
We need to do two things:
- Enqueue our
frontend.js
script only when that block is rendered. - Append attributes to what’s going to be rendered.
function create_block_interactive_block_block_init() { register_block_type( __DIR__, array( 'render_callback' => 'render_block_with_attribures' ) ); } // Copied from @wordpress/dependency-extraction-webpack-plugin docs. function enqueue_frontend_script() { $script_path = 'build/frontend.js'; $script_asset_path = 'build/frontend.asset.php'; $script_asset = require( $script_asset_path ); $script_url = plugins_url( $script_path, __FILE__ ); wp_enqueue_script( 'script', $script_url, $script_asset['dependencies'], $script_asset['version'] ); } // Copied from WooCommerce Blocks. function add_attributes_to_block( $whitelisted_blocks ) { $whitelisted_blocks[] = 'create-block/interactive-block'; return $whitelisted_blocks; } function render_block_with_attribures( $attributes = [], $content = '' ) { if ( ! is_admin() ) { enqueue_frontend_script(); } return $content; }; add_action( 'init', 'create_block_interactive_block_block_init' ); add_action( '__experimental_woocommerce_blocks_add_data_attributes_to_block', 'add_attributes_to_block' );
__experimental_woocommerce_blocks_add_data_attributes_to_block
handles adding attributes for you, if you want to handle that yourself, you can use the following code instead:
// Copied from WooCommerce Blocks. function add_attributes_to_block( $attributes = [], $content = '' ) { $escaped_data_attributes = []; foreach ( $attributes as $key => $value ) { if ( is_bool( $value ) ) { $value = $value ? 'true' : 'false'; } if ( ! is_scalar( $value ) ) { $value = wp_json_encode( $value ); } $escaped_data_attributes[] = 'data-' . esc_attr( strtolower( preg_replace( '/(?<!\ )[A-Z]/', '-$0', $key ) ) ) . '="' . esc_attr( $value ) . '"'; } return preg_replace( '/^<div /', '<div ' . implode( ' ', $escaped_data_attributes ) . ' ', trim( $content ) ); } function render_block_with_attribures( $attributes = [], $content = '' ) { if ( ! is_admin() ) { enqueue_frontend_script(); } return add_attributes_to_block($attributes, $content); };
Things to watch out for
When building your front-end-based components, make sure you’re not consuming anything editor-specific, so components from @wordpress/block-editor
or @wordpress/blocks
shouldn’t be used.
To make sure your final frontend.js
isn’t leaking extra large dependencies to the front end, you check the assets file, frontend.assets.php
.
This file, for example, would only load wp-element
and wp-polyfill
on the front end:
Common Questions
Why do we need add_attributes_to_block
?
That function does two things:
- It appends your attributes to the block
div
, this is because you can’t savedata-
attributes in the save function if you’re a non admin. - It handles the correct types.
What if I need more interactivity in the editor?
With this approach, you can add more interactivity in the editor, like using RichText
to allow users to introduce text, adding some events, or anything. In order to do so, you can break your Block
component into smaller chunks and use them inside the edit
function.
An example of this is the Filter Products by Price block, where the block heading is replaced by a component with the Gutenberg Block PlainText
, which allows directly editing the heading in the editor.
I need an innerBlocks structure
This is more complicated, but still possible. You can take a look at Checkout block included in WooCommerce Blocks 6.0.0, and the future iteration of the Cart block, currently only available in the development builds.
We used a similar approach in the past for the All Products and Single Product blocks (the latter, only available in dev
builds). However, we recommend using the Cart and Checkout blocks as the reference, given that their approach is more up to date.
Can I lazy load things
You’re already lazy loading things, but if you want to take your code a step further, you can lazy load your component in frontend.js
import { render, Suspense, lazy } from '@wordpress/element'; // modify webpack publicPath at runtime based on the location of your scripts. // eslint-disable-next-line camelcase, no-undef __webpack_public_path__ = 'https://example/wp-content/plugins/interactive-block/build/'; const Block = lazy( () => /* webpackChunkName: "lazy-loaded-blocks/interactive-blocks" */ import( './block' ) ); window.addEventListener( 'DOMContentLoaded', () => { const element = document.querySelector( '.wp-block-create-block-interactive-block' ); if ( element ) { const attributes = { ...element.dataset }; render( <Suspense fallback={ <div className="wp-block-placeholder" /> }> <Block { ...attributes } /> </Suspense>, element ); } } );
Assign __webpack_public_path__
to your plugin build path. We pass that value from the server.
How about lazy loading the block and its dependencies?
We are currently experimenting with a more aggressive lazy-loading approach for the Mini Cart block that would allow lazy-loading all dependencies. The way it works is as follows:
- Only a small JavaScript file is downloaded & parsed on each page where the Mini Cart block is present. That script is used to detect mouse hover/focus on the mini cart button, listen to
add_to_cart
events and to preload + append the other scripts. - Once the page is loaded, all other scripts are preloaded. That means they don’t block rendering the page and, instead, they are downloaded in the background after the page is loaded.
- Once the user interacts with the mini cart or adds a product to the cart, those scripts are appended to the page and, because of that, parsed and executed. The Mini Cart block is now completely interactive!
How about outside WordPress?
This is more complicated, but the block has nothing to do with it. Just the build part, code sharing aspect, and loading things.
Code
You can see the whole code for this demo in this GitHub repo: https://github.com/senadir/interactive-block.
Leave a Reply