Filterable Post List Block with WordPress Interactivity API

The Interactivity API is a new standard in WordPress for adding front-end interactivity to blocks. This means we have a standardized way to create beautiful and interactive user experiences without relying on external libraries. Sounds exciting? Let’s dive in!

What Are We Building?

We will create a new block called “Filterable post list” with the help of region-based navigation. The block displays a list of posts that can be filtered by category and tags. It also includes navigation links for browsing through pages.

The full code is available in the GitHub repository.

Here is the steps the follow for this article:

  1. Initializing a starter block template
  2. Rendering the block’s markup
  3. Adding interactivity

Step 1: Initializing the Starter Block Template

To get started, we will use the official Create Block script to initialize and register a new block. The script supports creating an interactive block by using interactive block template. Let’s run the following command:

npx @wordpress/create-block@latest filterable-post-list --template @wordpress/create-block-interactive-template

This will generate a functional block that supports the Interactivity API, which we will use as a foundation for our custom block.

Activate the plugin that was just created and start the development by running npm start inside the filterable-post-list directory.

Step 2: Rendering the Block’s Markup

We will get the data and render all of the HTML for our block with the standard PHP server-side rendering.

The block will display filters for sorting posts and the posts themselves, along with previous/next links for page navigation. The filters will include a dropdown for selecting a category and checkboxes for choosing tags. The post list will display only the post titles, which will serve as links to their respective single post pages.

In the render.php file, we will remove its existing contents and start fresh. Let’s begin by defining a unique ID, query parameters, and the current page:

$unique_id = wp_unique_id();

$page_query_var     = "filterable-post-list-page-{$unique_id}";
$category_query_var = "filterable-post-list-category-{$unique_id}";
$tags_query_var     = "filterable-post-list-tags-{$unique_id}";

$current_page = isset( $_GET[ $page_query_var ] ) ? (int) $_GET[ $page_query_var ] : 1;

We use a unique ID to ensure that if multiple blocks are added to the same page, each block functions independently. Using the unique ID, we will name our query variables for the current page, category, and tag filters. The current page will be stored in the $current_page variable if it’s set as part of the GET request or set to 1 if not provided.

Next, we will initialize a new WP_Query instance with arguments passed via the GET request:

$args = [
	'post_type'      => 'post',
	'posts_per_page' => 5,
	'orderby'        => 'date',
	'order'          => 'DESC',
	'paged'          => $current_page,
	'tax_query'      => [],
];

if ( isset( $_GET[ $category_query_var ] ) && ! empty( $_GET[ $category_query_var ] ) ) {
	$args['tax_query'][] = [
		'taxonomy' => 'category',
		'terms'    => $_GET[ $category_query_var ],
	];
}

if ( isset( $_GET[ $tags_query_var ] ) && ! empty( $_GET[ $tags_query_var ] ) ) {
	$args['tax_query'][] = [
		'taxonomy' => 'post_tag',
		'terms'    => explode( ',', $_GET[ $tags_query_var ] ),
		'operator' => 'IN',
	];
}

$query = new WP_Query( $args );

This is a standard query that will display posts based on the filters as follows:

  • If no GET parameters are set, display all posts for the current page.
  • If the category GET parameter is set, display all posts that belong to the specified category.
  • If the tags GET parameter is set, display all posts assigned to the specified tags. This parameter is comma-separated list of tag IDs. The relationship between tags is "IN" which means posts will be shown if they belong to one or more selected tags.

Additionally, let’s retrieve the categories and tags, along with the currently selected IDs for these filters:

$tags = get_tags( [ 'hide_empty' => 0 ] );
$categories = get_categories();

$selected_category = isset( $_GET[ $category_query_var ] ) ? (int) $_GET[ $category_query_var ] : 0;
$selected_tags     = isset( $_GET[ $tags_query_var ] ) ? array_map( 'absint', explode( ',', $_GET[ $tags_query_var ] ) ) : [];

We will use this data later in the code to display the category and tags filters.

Now, let’s start generating the HTML for the block by adding the opening <div> tag:

<div
	<?php echo get_block_wrapper_attributes(); ?>
	data-wp-interactive="create-block"
	<?php
	echo wp_interactivity_data_wp_context(
		[
			'selectedCategory' => $selected_category,
			'selectedTags'     => $selected_tags,
			'categoryQueryVar' => $category_query_var,
			'tagsQueryVar'     => $tags_query_var,
			'pageQueryVar'     => $page_query_var,
		]
	);
	?>
>

The wrapping <div> should include the block namespace to reference the store using data-wp-interactive directive, which is create-block in our case. In addition to the namespace, we also need to include the block context which can be added using the wp_interactivity_data_wp_context function.

The block context contains selectedCategory and selectedTags which store the current category ID and an array of selected tag IDs. While these values are already part of the URL, I chose to store them in the context to enhance the user experience. Updating the value in URL is slower, and storing the values in the context makes clicking filters feel more instant.

The remaining data in the context consists of query variables that we want to store for easy access.

The next steps is to add filters:

<div class="wp-block-create-block-filterable-post-list-filters">
	<div class="wp-block-create-block-filterable-post-list-filters__category">
		<label><?php esc_html_e( 'Category:', 'filterable-post-list' ); ?></label>
		<select data-wp-on--change="actions.updateCategory">
			<option value="0"><?php esc_html_e( '- All -', 'filterable-post-list' ); ?></option>
			<?php foreach ( $categories as $cat ) { ?>
			<option value="<?php echo $cat->term_id; ?>" <?php selected( $cat->term_id, $selected_category ); ?>><?php echo $cat->name; ?></option>
			<?php } ?>
		</select>
	</div>
	
	<div class="wp-block-create-block-filterable-post-list-filters__tags">
		<label><?php esc_html_e( 'Tags:', 'filterable-post-list' ); ?></label>
		<div class="wp-block-create-block-filterable-post-list-filters__tags-container">
			<?php foreach ( $tags as $tag ) { ?>
			<label class="wp-block-create-block-filterable-post-list-filters__tag-item">
				<input type="checkbox" data-wp-on--change="actions.updateTags" name="tag_id" value="<?php echo $tag->term_id; ?>" <?php checked( in_array( $tag->term_id, $selected_tags ) ); ?> />
				<?php echo $tag->name; ?>
			</label>
			<?php } ?>
		</div>
	</div>
</div>

The category filter is a dropdown populated with a list of categories that we loop through. We add the data-wp-on--change="actions.updateCategory" directive so that we can observe changes to the dropdown later in the store and update the UI accordingly.

The tags filter is a list of tags, where each tag is represented by a single checkbox. Each checkbox includes the data-wp-on--change="actions.updateTags" directive which will be used to track whether a checkbox is checked or unchecked.

The final part of the block is the list of posts together with page navigation:

<div
data-wp-interactive="create-block"
class="wp-block-create-block-filterable-post-list-posts"
<?php echo wp_interactivity_data_wp_context( [ 'pageQueryVar' => $page_query_var ] ); ?> 
data-wp-router-region="filterable-post-list-<?php echo $unique_id; ?>"
>
	<p><?php printf( __( 'Total results: %s', 'filterable-post-list' ), '<i>' . $query->found_posts . '</i>' ); ?></p>
	<div class="wp-block-create-block-filterable-post-list-posts__container">
		<?php
		if ( $query->have_posts() ) :
			while ( $query->have_posts() ) :
				$query->the_post();
				?>
		<div class="wp-block-create-block-filterable-post-list-posts__item">
			<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
		</div>
				<?php
			endwhile;
		else :
			?>
		<div class="wp-block-create-block-filterable-post-list-posts__item">
			<p><?php esc_html_e( 'No results found.', 'filterable-post-list' ); ?></p>
		</div>
			<?php
		endif;
		wp_reset_postdata();
		?>
	</div>

	<?php if ( $query->have_posts() ) : ?>
	<div class="wp-block-create-block-filterable-post-list-posts__navigation">
		<div class="wp-block-create-block-filterable-post-list-posts__navigation--previous">
		<?php if ( $current_page > 1 ) : ?>
			<a href="<?php echo add_query_arg( [ $page_query_var => $current_page - 1 ] ); ?>" data-wp-on--click="actions.previous"><?php esc_html_e( '&laquo; Previous', 'filterable-post-list' ); ?></a>
		<?php endif; ?>
		</div>

		<div class="wp-block-create-block-filterable-post-list-posts__navigation--next">
		<?php if ( $current_page < $query->max_num_pages ) : ?>
			<a href="<?php echo add_query_arg( [ $page_query_var => $current_page + 1 ] ); ?>" data-wp-on--click="actions.next"><?php esc_html_e( 'Next &raquo;', 'filterable-post-list' ); ?></a>
		<?php endif; ?>
		</div>
	</div>
	<?php endif; ?>
</div>
</div><!-- closing div for the entire block -->

The list of posts is the core part of the block managed with client-side region-based navigation. To enable client-side navigation for this section, it must include the data-wp-router-region directive. The value of the directive should be unique per page so that Interactivity API router knows which section to update. For this reason, we are using $unique_id as part of the directive’s value.

The way client-side navigation works is by fetching the server-side rendered section of this block and replacing the existing HTML with the newly generated HTML. The new HTML will be different based on the parameters passed in the GET request. The Interactivity router handles this efficiently by fetching the new markup and updating the corresponding section of the block.

Essentially, everything that can be server-side rendered should be server-side rendered!

A standard query loop is used to iterate through the posts and display them. If no posts are found, a simple “No results found.” message is displayed.

The page navigation includes “Previous” and “Next” links that are dynamically shown or hidden based on the current page. Basic logic checks whether the current page is the first or last to determine the visibility of these links. The “Next” link includes the data-wp-on--click="actions.next" directive and the “Previous” link has the data-wp-on--click="actions.previous" directive. These directives will later trigger the corresponding next and previous functions from the store enabling smooth page switching in the UI.

Step 3: Adding Interactivity

So far, we have created the server-rendered version of the block. Now comes the fun part – adding interactivity! Isn’t the user experience so much better without requiring a full page reload? Absolutely!

The interactive parts of the block will include instant navigation between pages and real-time filtering based on the selected filters.

In the view.js file, we will start by removing the state and callbacks properties from the store because we won’t need them. This block doesn’t require any global state or callback functions. The only property the store will need is actions.

Let’s start by adding actions for page navigation. This requires two functions to handle clicks on the “Next” and “Previous” links:

*next( e ) {
	e.preventDefault();
	const ctx = getContext();
	const url = new URL( window.location );

	const currentPage = +url.searchParams.get( ctx.pageQueryVar );
	let nextPage = 2;

	if ( ! isNaN( currentPage ) && currentPage !== 0 ) {
		nextPage = currentPage + 1;
	}

	url.searchParams.set( ctx.pageQueryVar, nextPage );

	const { actions } = yield import(
		'@wordpress/interactivity-router'
	);
	yield actions.navigate(
		`${ window.location.pathname }${ url.search }`
	);
},
*previous( e ) {
	e.preventDefault();
	const ctx = getContext();
	const url = new URL( window.location );

	const currentPage = +url.searchParams.get( ctx.pageQueryVar );
	let previousPage = 1;

	if ( ! isNaN( currentPage ) && currentPage !== 0 ) {
		previousPage = currentPage - 1;
	}

	url.searchParams.set( ctx.pageQueryVar, previousPage );

	const { actions } = yield import(
		'@wordpress/interactivity-router'
	);
	yield actions.navigate(
		`${ window.location.pathname }${ url.search }`
	);
},

These two functions are nearly identical and could potentially be combined into a single function with different arguments. However, I would prefer to keep them separate for clarity.

The logic of each function is simple:

  • Get the current URL of the page and look for pageQueryVar. We need to know if it exists and what’s the current page number
  • Increment the page number and update it in the URL
  • Use the navigate function from the Interactivity router to navigate to the new URL

First, we define both functions as generator functions to handle the asynchronous operation. When a function is asynchronous, we need to know when it’s going to start and finish so that scope can be properly set.

Next, we get the local context and the current URL of the page. Earlier in PHP, we previously passed pageQueryVar to the context so we will know name of the page query variable and update it in the URL.

After incrementing the page number, we construct the new URL with the updated page number. Finally, we import @wordpress/interactivity-router asynchronously and use its actions.navigate function to navigate to the new URL.

Routing to the new URL with the Interactivity router is doing the following actions:

  • Updates the URL in the address bar
  • Sends a fetch request to the updated URL to get the server-side rendered content based on the query parameters. The response contains the new HTML reflecting the updated query variables
  • Identifies interactive regions in the current DOM (marked with data-wp-router-region) and in the newly received server response
  • Compares the current region with the updated one and replaces the DOM content with the new content

The region marked with data-wp-router-region only includes the section displaying the list of posts and page navigation. The filters section doesn’t have any dynamic data so we will not include it in the router section.

Next, let’s add the function to update the posts list when the category is changed:

*updateCategory( e ) {
	const ctx = getContext();
	const categoryId = e.target.value;
	const url = new URL( window.location );

	// Update URL with new category ID and reset pagination.
	url.searchParams.set( ctx.categoryQueryVar, categoryId );
	url.searchParams.set( ctx.pageQueryVar, 1 );

	// Update local context.
	ctx.selectedCategory = +categoryId;

	const { actions } = yield import(
		'@wordpress/interactivity-router'
	);
	yield actions.navigate(
		`${ window.location.pathname }${ url.search }`
	);
},

This function will take the newly selected category ID, update the context with the current category, construct a new URL, and navigate to that page using the Interactivity router.

As with the previous navigation logic, we are simply routing to the new URL, and the router region will update automatically based on the new query parameters.

Finally, let’s add the function for the tags filter:

*updateTags( e ) {
	e.preventDefault();
	const ctx = getContext();
	const tagId = e.target.value;
	const url = new URL( window.location );

	const existingTags = url.searchParams.get( ctx.tagsQueryVar );

	// Create an array out of existing tag IDs from URL.
	const tagIds = existingTags
		? existingTags.split( ',' ).map( ( id ) => id.trim() )
		: [];

	// Toggle tag ID in array.
	if ( ! tagIds.includes( tagId ) ) {
		tagIds.push( tagId );
	} else {
		tagIds.splice( tagIds.indexOf( tagId ), 1 );
	}

	// Update URL with new tag IDs and reset pagination.
	url.searchParams.set( ctx.tagsQueryVar, tagIds.join( ',' ) );
	url.searchParams.set( ctx.pageQueryVar, 1 );

	// Update local context.
	ctx.selectedTags = tagIds;

	const { actions } = yield import(
		'@wordpress/interactivity-router'
	);
	yield actions.navigate(
		`${ window.location.pathname }${ url.search }`
	);
},

The tags filter function is very similar to the category filter, except that it handles multiple IDs. The query parameter for tags is a comma-separated list of IDs.

Once again, we listen for checkbox change events, construct the URL based on the selected tag IDs, update the context, and navigate to the new URL.

And that’s it – we’re done! 🎉

The result

That’s all the code needed to create a fully functional block using region-based navigation!

The styling only requires a few additional lines of CSS, which you can grab from here.

Want to see something cool? Disable JavaScript on the page where the block is displayed and take a look! All the HTML is present because it’s server-side rendered. You can even navigate between pages, and everything works perfectly fine!

The filters are forms, so they don’t trigger a page reload on change. However, this could be addressed by adding a form submit button wrapped in a <noscript> tag which would only be visible when JavaScript is disabled. When the form is submitted, the correctly filtered posts would be displayed.

I have found this approach to building blocks both practical and enjoyable to work with. It’s undoubtedly one of the best ways to add interactive functionality to the front end. Using just a small amount of code, you can create something that is highly optimized and follows the best practices.

I’m confident the Interactivity API has a bright future ahead! 🚀

Leave a Reply

Your email address will not be published. Required fields are marked *